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 AdminClientAsync() { var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); return client; } private static async Task 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(); 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("/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__ where 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("/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(); 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(); 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(); 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(); 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("/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(); 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(); Assert.NotNull(child); var roots = await bearerClient.GetFromJsonAsync("/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(); 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($"/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(); 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(); Assert.NotNull(attachment); var listed = await bearerClient.GetFromJsonAsync($"/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(); 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); } }