Files

375 lines
19 KiB
C#

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);
}
}