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,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);
|
||||
}
|
||||
}
|
||||
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user