Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
commit ad8ad252a9
825 files changed
+124216

No files matched your search

@@ -0,0 +1,83 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Automation;
/// <summary>A retried create/append request carrying the same
/// <c>Idempotency-Key</c> must never execute the mutation twice
/// (docs/09-api-contract.md).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class IdempotencyTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Retrying_a_project_update_with_the_same_Idempotency_Key_does_not_create_a_second_update()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects",
new CreateProjectRequest("Idempotentieproject", "S.", "Medium", "Start.", Guid.NewGuid()));
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var idempotencyKey = Guid.NewGuid().ToString();
var request = new CreateProjectUpdateRequest(
"Progress", "Idempotente update", "Samenvatting.", "Reported", null, null, null, null, null, null, null, null, null, null);
var first = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", request, idempotencyKey);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var firstBody = await first.Content.ReadFromJsonAsync<ProjectUpdateSummary>();
var second = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", request, idempotencyKey);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
var secondBody = await second.Content.ReadFromJsonAsync<ProjectUpdateSummary>();
// The replayed response is the exact cached original — same update ID — not a
// freshly created second row.
Assert.Equal(firstBody!.Id, secondBody!.Id);
var timeline = await client.GetFromJsonAsync<ProjectUpdatesEnvelope>($"/api/v1/projects/{project.Id}/updates");
Assert.NotNull(timeline);
Assert.Single(timeline.Items, update => update.Title == "Idempotente update");
}
[Fact]
public async Task Reusing_an_Idempotency_Key_with_different_request_content_is_a_conflict()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects",
new CreateProjectRequest("Conflictproject idempotentie", "S.", "Medium", "Start.", Guid.NewGuid()));
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var idempotencyKey = Guid.NewGuid().ToString();
var first = new CreateProjectUpdateRequest(
"Progress", "Eerste inhoud", "S.", "Reported", null, null, null, null, null, null, null, null, null, null);
var different = new CreateProjectUpdateRequest(
"Progress", "Andere inhoud", "S.", "Reported", null, null, null, null, null, null, null, null, null, null);
var firstResponse = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", first, idempotencyKey);
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
var conflictResponse = await PostWithIdempotencyKeyAsync(client, $"/api/v1/projects/{project.Id}/updates", different, idempotencyKey);
Assert.Equal(HttpStatusCode.Conflict, conflictResponse.StatusCode);
}
private static async Task<HttpResponseMessage> PostWithIdempotencyKeyAsync<TBody>(
HttpClient client, string path, TBody body, string idempotencyKey)
{
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.Add("Idempotency-Key", idempotencyKey);
return await client.SendAsync(request);
}
private sealed record ProjectUpdatesEnvelope(IReadOnlyList<ProjectUpdateSummary> Items, string? NextCursor);
}
@@ -0,0 +1,88 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Automation;
/// <summary>Every mutation this milestone wires into the outbox must produce a
/// durable, correctly-typed row in the same transaction as the mutation itself
/// (docs/24-mcp-readiness.md "Outbox events", M6-A06) — proven here by reading the
/// table directly, the same "no consumer exists yet, but the record must be there and
/// correct for one later" contract the outbox is designed around.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class OutboxTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Creating_a_page_records_a_page_created_outbox_event_in_the_same_transaction()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Outbox-testpagina", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var messages = await db.OutboxMessages
.Where(message => message.EventType == "page.created.v1" && message.AggregateId == page.Id.ToString())
.ToListAsync();
var message = Assert.Single(messages);
Assert.False(message.IsProcessed);
Assert.Contains(page.Id.ToString(), message.PayloadJson, StringComparison.Ordinal);
}
[Fact]
public async Task Publishing_a_page_edit_records_a_page_updated_outbox_event()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Bij te werken pagina", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Bijgewerkte titel", "Nieuwe inhoud", "WikiPage", "Active", null), page.ETag);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var messages = await db.OutboxMessages
.Where(message => message.EventType == "page.updated.v1" && message.AggregateId == page.Id.ToString())
.ToListAsync();
Assert.Single(messages);
}
[Fact]
public async Task Unprocessed_outbox_messages_are_queryable_oldest_first_a_future_dispatcher_could_replay_them()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Herstel-test pagina 1", "WikiPage", "I.", null));
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Herstel-test pagina 2", "WikiPage", "I.", null));
using var scope = Factory.Services.CreateScope();
var outbox = scope.ServiceProvider.GetRequiredService<ItWorx.KnowledgeHub.Application.Automation.IOutboxRepository>();
var unprocessed = await outbox.GetUnprocessedAsync(500, CancellationToken.None);
Assert.True(unprocessed.Count >= 2);
Assert.All(unprocessed, message => Assert.False(message.IsProcessed));
// Oldest first: monotonically non-decreasing AvailableAt.
for (var i = 1; i < unprocessed.Count; i++)
{
Assert.True(unprocessed[i].AvailableAt >= unprocessed[i - 1].AvailableAt);
}
}
}
@@ -0,0 +1,101 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Automation;
/// <summary>The bounded project context bundle (M6-A04): explicit maximum item
/// counts, deterministic ordering, and truncation metadata rather than silently
/// dropping items (docs/09-api-contract.md "Project context").</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class ProjectContextTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Project_context_reports_no_truncation_when_everything_fits_within_the_bounds()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects", new CreateProjectRequest("Contextproject", "S.", "Medium", "Start.", Guid.NewGuid()));
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/projects/{project.Id}/updates",
new CreateProjectUpdateRequest("Progress", "Update 1", "S.", "Reported", null, null, null, null, null, null, null, null, null, null));
var context = await client.GetFromJsonAsync<ProjectContextResponse>($"/api/v1/projects/{project.Id}/context");
Assert.NotNull(context);
Assert.Equal(project.Id, context.Project.Id);
Assert.False(context.Truncation.Truncated);
Assert.Empty(context.Truncation.OmittedCounts);
Assert.Single(context.RecentUpdates);
}
[Fact]
public async Task Project_context_truncates_updates_beyond_the_requested_bound_and_reports_the_omitted_count()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects", new CreateProjectRequest("Afgekapt project", "S.", "Medium", "Start.", Guid.NewGuid()));
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
for (var i = 0; i < 3; i++)
{
await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/projects/{project.Id}/updates",
new CreateProjectUpdateRequest("Progress", $"Update {i}", "S.", "Reported", null, null, null, null, null, null, null, null, null, null));
}
var context = await client.GetFromJsonAsync<ProjectContextResponse>($"/api/v1/projects/{project.Id}/context?updates=2");
Assert.NotNull(context);
Assert.Equal(2, context.RecentUpdates.Count);
Assert.True(context.Truncation.Truncated);
Assert.Equal(1, context.Truncation.OmittedCounts["updates"]);
}
[Fact]
public async Task Project_context_carries_stable_citations_for_every_included_document()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects", new CreateProjectRequest("Citatieproject", "S.", "Medium", "Start.", Guid.NewGuid()));
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var pageResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages",
new ItWorx.KnowledgeHub.Contracts.Knowledge.CreatePageRequest(null, "Architectuurpagina", "WikiPage", "# A", null));
var page = await pageResponse.Content.ReadFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Knowledge.PageDetail>();
Assert.NotNull(page);
await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/projects/{project.Id}/documents", new LinkProjectDocumentRequest(page.Id, "Architecture"));
var context = await client.GetFromJsonAsync<ProjectContextResponse>($"/api/v1/projects/{project.Id}/context");
Assert.NotNull(context);
var citation = Assert.Single(context.Citations, item => item.Id == page.Id);
Assert.Equal("page", citation.Kind);
Assert.Equal(page.Title, citation.Title);
}
[Fact]
public async Task Project_context_is_not_reachable_without_authentication()
{
using var client = Factory.CreateClient();
var response = await client.GetAsync($"/api/v1/projects/{Guid.NewGuid()}/context");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
@@ -0,0 +1,374 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Automation;
[Collection(PostgresTestGroup.Name)]
public sealed class ServiceAccountTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private async Task<HttpClient> AdminClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
private static async Task<CreatedServiceAccountToken> CreateServiceAccountAsync(
HttpClient adminClient, string name, params string[] scopes)
{
var response = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/service-accounts", new CreateServiceAccountRequest(name, "Testaccount.", scopes));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(created);
return created;
}
private static HttpClient BearerClient(KnowledgeHubIntegrationFactory factory, string rawToken)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", rawToken);
return client;
}
[Fact]
public async Task Creating_a_service_account_returns_the_raw_token_exactly_once_and_it_is_never_persisted_in_the_clear()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "CI-bot", Scopes.KnowledgeSearch);
Assert.False(string.IsNullOrWhiteSpace(created.RawToken));
Assert.StartsWith("ikh_", created.RawToken, StringComparison.Ordinal);
var list = await adminClient.GetFromJsonAsync<ServiceAccountListResponse>("/api/v1/service-accounts");
Assert.NotNull(list);
var account = Assert.Single(list.Items, item => item.Id == created.ServiceAccountId);
var token = Assert.Single(account.Tokens);
// The listing never re-exposes the secret — only the safe public ID and
// metadata (docs/09-api-contract.md: "The API never returns or logs the
// secret."). ikh_<public-id>_<secret> where <public-id> is always exactly 12
// base64url characters (ServiceAccountTokenIssuer.PublicIdLength) — parsed by
// fixed length, not by scanning for '_', because the base64url alphabet itself
// can contain '_' (see ADR-015 and ServiceAccountTokenIssuer's own comment for
// the real, reproducing bug this fixed length boundary replaced).
var secret = created.RawToken["ikh_".Length..][13..];
Assert.NotEqual(created.RawToken, token.PublicId);
Assert.DoesNotContain(secret, token.PublicId, StringComparison.Ordinal);
}
[Fact]
public async Task A_valid_bearer_token_with_the_correct_scope_can_call_a_scoped_endpoint()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Search-bot", Scopes.KnowledgeSearch);
using var bearerClient = BearerClient(Factory, created.RawToken);
var response = await bearerClient.GetAsync("/api/v1/search?q=test");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task A_bearer_token_without_the_required_scope_is_forbidden_vertical_privilege_escalation()
{
using var adminClient = await AdminClientAsync();
// Deliberately only a read scope for an unrelated resource — never pages:write.
var created = await CreateServiceAccountAsync(adminClient, "Read-only-bot", Scopes.KnowledgeSearch);
using var bearerClient = BearerClient(Factory, created.RawToken);
var response = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, "Ongeautoriseerde pagina", "WikiPage", "Inhoud.", null));
// The mutation must never be allowed to proceed — that is the actual security
// property M6-A02 requires. ASP.NET Core's combined cookie+ServiceToken policy
// evaluation reports this specific case (authenticated via ServiceToken, but
// missing the required scope, with no cookie present at all) as 401 rather
// than the arguably more precise 403; both correctly deny the request, and
// this is a documented framework nuance (ADR-015), not a security gap — no
// page is ever created either way (asserted below).
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected the escalation attempt to be denied, but got {response.StatusCode}.");
var list = await adminClient.GetFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Knowledge.PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(list);
Assert.DoesNotContain(list.Items, page => page.Title == "Ongeautoriseerde pagina");
}
[Fact]
public async Task A_bearer_token_with_pages_write_can_create_a_page_and_its_scope_does_not_grant_project_access_horizontal_escalation()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Pages-bot", Scopes.PagesWrite, Scopes.PagesRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var createResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, "Via service-account aangemaakt", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var readResponse = await bearerClient.GetAsync($"/api/v1/pages/{page.Id}");
Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
// pages:write/pages:read never implicitly grant projects:read — scopes are
// narrow and additive, never bundled (M6-A02).
var projectsResponse = await bearerClient.GetAsync("/api/v1/projects");
Assert.Equal(HttpStatusCode.Forbidden, projectsResponse.StatusCode);
}
[Fact]
public async Task A_bearer_token_with_projects_write_can_create_a_project_and_it_is_attributed_to_the_service_account()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Infra-bot", Scopes.ProjectsWrite, Scopes.ProjectsRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var createResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/projects",
new CreateProjectRequest("Via serviceaccount aangemaakt", "Samenvatting.", "Medium", "Startsituatie.", Guid.NewGuid()));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var project = await createResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var readResponse = await bearerClient.GetAsync($"/api/v1/projects/{project.Id}");
Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
// projects:write/projects:read never implicitly grant pages:read — scopes are
// narrow and additive, never bundled (M6-A02), same property as the mirrored
// pages:write test above, now checked from the other direction too.
var pagesResponse = await bearerClient.GetAsync("/api/v1/pages/roots");
Assert.Equal(HttpStatusCode.Forbidden, pagesResponse.StatusCode);
}
[Fact]
public async Task A_bearer_token_with_projects_write_can_update_a_project_it_created_with_concurrency_and_idempotency()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Infra-update-bot", Scopes.ProjectsWrite, Scopes.ProjectsRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var createResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/projects",
new CreateProjectRequest("Update via serviceaccount", "Samenvatting.", "Medium", "Startsituatie.", Guid.NewGuid()));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var project = await createResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var updateRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/projects/{project.Id}")
{
Content = JsonContent.Create(new UpdateProjectRequest(
project.Name, project.Summary, project.Priority, project.CurrentStateMarkdown, project.OwnerId,
null, null, null, "https://gitea.example/owner/repo", "https://example.test", null, null, ["dotnet", "react"])),
};
updateRequest.Headers.IfMatch.ParseAdd($"\"{project.ETag}\"");
var updateResponse = await bearerClient.SendAsync(updateRequest);
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updated = await updateResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(updated);
Assert.Equal("https://gitea.example/owner/repo", updated.RepositoryUrl);
Assert.Equal(["dotnet", "react"], updated.Technologies);
}
[Fact]
public async Task A_bearer_token_without_projects_write_cannot_create_a_project_vertical_privilege_escalation()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Read-only-project-bot", Scopes.ProjectsRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var response = await bearerClient.PostAsJsonAsync(
"/api/v1/projects",
new CreateProjectRequest("Ongeautoriseerd project", "S", "Medium", "S", Guid.NewGuid()));
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected the escalation attempt to be denied, but got {response.StatusCode}.");
var list = await adminClient.GetFromJsonAsync<ProjectListResponse>("/api/v1/projects");
Assert.NotNull(list);
Assert.DoesNotContain(list.Items, project => project.Name == "Ongeautoriseerd project");
}
[Fact]
public async Task A_scoped_curation_token_can_inventory_update_and_move_pages_with_concurrency_and_idempotency()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Curation-bot", Scopes.PagesWrite, Scopes.PagesRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var parentResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, $"Curatie {Guid.NewGuid():N}", "WikiPage", "Bovenliggend.", null));
parentResponse.EnsureSuccessStatusCode();
var parent = await parentResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(parent);
var childResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, $"Bron {Guid.NewGuid():N}", "WikiPage", "Broninhoud.", null));
childResponse.EnsureSuccessStatusCode();
var child = await childResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(child);
var roots = await bearerClient.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots?limit=100");
Assert.NotNull(roots);
Assert.Contains(roots.Items, item => item.Id == child.Id);
using var update = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/pages/{child.Id}")
{
Content = JsonContent.Create(new UpdatePageRequest(
child.Title, child.Markdown, child.DocumentType, "Active", "Bron gevalideerd.", child.Tags))
};
update.Headers.TryAddWithoutValidation("If-Match", child.ETag);
update.Headers.TryAddWithoutValidation("Idempotency-Key", $"curation-update-{child.Id:N}");
var updateResponse = await bearerClient.SendAsync(update);
updateResponse.EnsureSuccessStatusCode();
var updated = await updateResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(updated);
using var move = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/pages/{child.Id}/move")
{
Content = JsonContent.Create(new MovePageRequest(parent.Id, null))
};
move.Headers.TryAddWithoutValidation("If-Match", updated.ETag);
move.Headers.TryAddWithoutValidation("Idempotency-Key", $"curation-move-{child.Id:N}");
var moveResponse = await bearerClient.SendAsync(move);
moveResponse.EnsureSuccessStatusCode();
var children = await bearerClient.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(children);
Assert.Contains(children.Items, item => item.Id == child.Id);
var revisions = await bearerClient.GetAsync($"/api/v1/pages/{child.Id}/revisions");
Assert.Equal(HttpStatusCode.OK, revisions.StatusCode);
var backlinks = await bearerClient.GetAsync($"/api/v1/pages/{child.Id}/backlinks");
Assert.Equal(HttpStatusCode.OK, backlinks.StatusCode);
}
[Fact]
public async Task A_scoped_ingestion_token_can_upload_list_and_read_attachments_then_trash_its_page_for_rollback()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(
adminClient, "Ingestion-bot", Scopes.PagesRead, Scopes.PagesWrite,
Scopes.AttachmentsRead, Scopes.AttachmentsWrite);
using var bearerClient = BearerClient(Factory, created.RawToken);
var createResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, $"Importbatch {Guid.NewGuid():N}", "WikiPage", "Broninhoud.", null));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.NotNull(createResponse.Headers.ETag);
var etag = createResponse.Headers.ETag.ToString();
using var multipart = new MultipartFormDataContent();
var file = new ByteArrayContent("veilige broninhoud"u8.ToArray());
file.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
multipart.Add(file, "file", "bron.txt");
var uploadResponse = await bearerClient.PostAsync($"/api/v1/pages/{page.Id}/attachments", multipart);
Assert.Equal(HttpStatusCode.OK, uploadResponse.StatusCode);
var attachment = await uploadResponse.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
var listed = await bearerClient.GetFromJsonAsync<AttachmentListResponse>($"/api/v1/pages/{page.Id}/attachments");
Assert.NotNull(listed);
Assert.Contains(listed.Items, item => item.Id == attachment.Id);
var downloaded = await bearerClient.GetByteArrayAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal("veilige broninhoud"u8.ToArray(), downloaded);
using var trashRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/pages/{page.Id}/trash");
trashRequest.Headers.TryAddWithoutValidation("If-Match", etag);
var trashResponse = await bearerClient.SendAsync(trashRequest);
Assert.Equal(HttpStatusCode.OK, trashResponse.StatusCode);
}
[Fact]
public async Task An_ingestion_token_without_attachments_write_cannot_upload()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(
adminClient, "Attachment-read-only-bot", Scopes.PagesWrite, Scopes.AttachmentsRead);
using var bearerClient = BearerClient(Factory, created.RawToken);
var createResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/pages", new CreatePageRequest(null, $"Scopeproef {Guid.NewGuid():N}", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
using var multipart = new MultipartFormDataContent();
multipart.Add(new ByteArrayContent("niet toegestaan"u8.ToArray()), "file", "verboden.txt");
var uploadResponse = await bearerClient.PostAsync($"/api/v1/pages/{page.Id}/attachments", multipart);
Assert.True(uploadResponse.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized);
}
[Fact]
public async Task A_revoked_token_is_rejected_on_the_very_next_request()
{
using var adminClient = await AdminClientAsync();
var created = await CreateServiceAccountAsync(adminClient, "Kort-levend-bot", Scopes.KnowledgeSearch);
using var bearerClient = BearerClient(Factory, created.RawToken);
var beforeRevoke = await bearerClient.GetAsync("/api/v1/search?q=test");
Assert.Equal(HttpStatusCode.OK, beforeRevoke.StatusCode);
var revokeResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, $"/api/v1/service-accounts/tokens/{created.TokenId}/revoke", (object?)null);
Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
var afterRevoke = await bearerClient.GetAsync("/api/v1/search?q=test");
Assert.Equal(HttpStatusCode.Unauthorized, afterRevoke.StatusCode);
}
[Fact]
public async Task An_invalid_bearer_token_is_rejected()
{
using var bearerClient = BearerClient(Factory, "ikh_bogus_notarealtoken");
var response = await bearerClient.GetAsync("/api/v1/search?q=test");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Only_an_administrator_can_create_service_accounts()
{
using var adminClient = await AdminClientAsync();
const string editorEmail = "beheerder-test@itworx.test";
const string editorPassword = "editor-strong-password";
var createUserResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/admin/users", new CreateUserRequest("Redacteur", editorEmail, editorPassword, "Editor"));
Assert.Equal(HttpStatusCode.OK, createUserResponse.StatusCode);
using var editorClient = Factory.CreateClient();
await SetupHelper.LoginAsync(editorClient, editorEmail, editorPassword);
var response = await SetupHelper.PostWithCsrfAsync(
editorClient, "/api/v1/service-accounts", new CreateServiceAccountRequest("Poging", "S.", [Scopes.KnowledgeSearch]));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Creating_a_service_account_with_an_unknown_scope_is_rejected()
{
using var adminClient = await AdminClientAsync();
var response = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/service-accounts", new CreateServiceAccountRequest("Ongeldig", "S.", ["not-a-real-scope"]));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
}
@@ -0,0 +1,61 @@
using ItWorx.KnowledgeHub.Infrastructure.Automation;
namespace ItWorx.KnowledgeHub.IntegrationTests.Automation;
/// <summary>
/// Pure, deterministic parsing tests for <see cref="ServiceAccountTokenIssuer"/> — no
/// database or container required. Regression coverage for a real defect: the
/// base64url alphabet used for the public ID can itself contain '_', the same
/// character used to delimit it from the secret. A naive `Split('_', 3)` mis-parsed
/// roughly 1 in 6 randomly generated tokens; this was found via a flaky integration
/// test (<see cref="ServiceAccountTests"/>) failing its very first bearer-authenticated
/// call, not by inspection. See ADR-015.
/// </summary>
public sealed class ServiceAccountTokenIssuerTests
{
private readonly ServiceAccountTokenIssuer _issuer = new();
[Fact]
public void Round_trips_a_freshly_issued_token()
{
var issued = _issuer.IssueNewToken();
var parsed = _issuer.TryParse(issued.RawToken, out var publicId, out var secret);
Assert.True(parsed);
Assert.Equal(issued.PublicId, publicId);
Assert.Equal(_issuer.HashSecret(secret), issued.SecretHash);
}
[Fact]
public void A_public_id_that_itself_contains_an_underscore_is_still_parsed_correctly()
{
// A synthetic token shaped exactly like a real one, deliberately constructed so
// the 12-character public ID contains '_' characters — the exact case that
// broke a delimiter-scanning parser.
const string publicIdWithUnderscores = "a_b_c_d_e_f_";
const string secret = "the-real-secret-value";
var rawToken = $"ikh_{publicIdWithUnderscores}_{secret}";
var parsed = _issuer.TryParse(rawToken, out var publicId, out var parsedSecret);
Assert.True(parsed);
Assert.Equal(publicIdWithUnderscores, publicId);
Assert.Equal(secret, parsedSecret);
}
[Theory]
[InlineData("")]
[InlineData("not-a-token")]
[InlineData("ikh_")]
[InlineData("ikh_short")]
[InlineData("ikh_123456789012")]
[InlineData("ikh_123456789012_")]
[InlineData("bearer_123456789012_secret")]
public void Malformed_or_incomplete_tokens_are_rejected(string presented)
{
var parsed = _issuer.TryParse(presented, out _, out _);
Assert.False(parsed);
}
}