Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user