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,72 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Backlinks ("what links here") update correctly after link, title, slug and
/// tree changes (docs/27-acceptance-matrix.md, M3-A03).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class BacklinksTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task A_page_appears_in_another_pages_backlinks_once_it_links_there_and_disappears_once_the_link_is_removed()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var targetResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Doelpagina", "WikiPage", "De doelpagina.", null));
var target = await targetResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(target);
var sourceResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Bronpagina", "WikiPage", "Zie [[Doelpagina]].", null));
var source = await sourceResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(source);
var backlinks = await client.GetFromJsonAsync<BacklinksResponse>($"/api/v1/pages/{target.Id}/backlinks");
Assert.NotNull(backlinks);
Assert.Contains(backlinks.Items, entry => entry.Id == source.Id);
// Renaming the target must not break the already-resolved backlink relationship.
var renamedTargetResponse = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{target.Id}",
new UpdatePageRequest("Doelpagina (hernoemd)", "De doelpagina.", "WikiPage", "Active", null),
target.ETag);
Assert.True(renamedTargetResponse.IsSuccessStatusCode);
var backlinksAfterRename = await client.GetFromJsonAsync<BacklinksResponse>($"/api/v1/pages/{target.Id}/backlinks");
Assert.NotNull(backlinksAfterRename);
Assert.Contains(backlinksAfterRename.Items, entry => entry.Id == source.Id);
// Removing the link from the source page removes the backlink.
var unlinkedSourceResponse = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{source.Id}",
new UpdatePageRequest("Bronpagina", "Geen verwijzing meer.", "WikiPage", "Active", null),
source.ETag);
Assert.True(unlinkedSourceResponse.IsSuccessStatusCode);
var backlinksAfterUnlink = await client.GetFromJsonAsync<BacklinksResponse>($"/api/v1/pages/{target.Id}/backlinks");
Assert.NotNull(backlinksAfterUnlink);
Assert.DoesNotContain(backlinksAfterUnlink.Items, entry => entry.Id == source.Id);
}
[Fact]
public async Task A_page_with_no_incoming_links_has_an_empty_backlinks_list()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Eenzame pagina", "WikiPage", "Niemand linkt hierheen.", null));
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var backlinks = await client.GetFromJsonAsync<BacklinksResponse>($"/api/v1/pages/{page.Id}/backlinks");
Assert.NotNull(backlinks);
Assert.Empty(backlinks.Items);
}
}
@@ -0,0 +1,92 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Autosaved drafts survive navigation/restart without overwriting a newer
/// server revision (docs/27-acceptance-matrix.md, M2-A04).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class DraftAutosaveTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task A_saved_draft_can_be_retrieved_later_as_if_after_a_browser_restart()
{
using var writer = Factory.CreateClient();
await SetupHelper.LoginAsync(writer, OwnerEmail, OwnerPassword);
var draftKey = $"new-page-{Guid.NewGuid():N}";
var saveResponse = await SetupHelper.PutWithCsrfAsync(
writer, $"/api/v1/drafts/{draftKey}", new SaveDraftRequest(null, null, "Concepttitel", "Conceptinhoud"));
Assert.Equal(HttpStatusCode.OK, saveResponse.StatusCode);
// A brand new HttpClient with its own (empty) cookie jar, logging in again —
// simulating the browser having fully restarted.
using var afterRestart = Factory.CreateClient();
await SetupHelper.LoginAsync(afterRestart, OwnerEmail, OwnerPassword);
var fetched = await afterRestart.GetFromJsonAsync<DraftResponse>($"/api/v1/drafts/{draftKey}");
Assert.NotNull(fetched);
Assert.Equal("Concepttitel", fetched.Title);
Assert.Equal("Conceptinhoud", fetched.Markdown);
}
[Fact]
public async Task Saving_a_draft_against_an_outdated_base_revision_is_flagged_as_stale_not_silently_accepted()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Concepttest pagina", "WikiPage", "Origineel", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
// A draft is started against revision 1...
var draftKey = page.Id.ToString();
await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/drafts/{draftKey}", new SaveDraftRequest(page.Id, page.CurrentRevisionNumber, page.Title, "Conceptwijziging"));
// ...but someone else (or another tab) publishes revision 2 in the meantime.
await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Gepubliceerde titel", "Gepubliceerde inhoud", "WikiPage", "Active", null),
page.ETag);
// Saving the draft again must surface that its base revision is now stale,
// rather than silently proceeding as if nothing happened.
var staleSave = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/drafts/{draftKey}", new SaveDraftRequest(page.Id, page.CurrentRevisionNumber, page.Title, "Nog een conceptwijziging"));
var staleDraft = await staleSave.Content.ReadFromJsonAsync<DraftResponse>();
Assert.NotNull(staleDraft);
Assert.True(staleDraft.BaseRevisionIsStale);
// Crucially, the published page itself was never overwritten by the draft.
var currentPage = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{page.Id}");
Assert.NotNull(currentPage);
Assert.Equal("Gepubliceerde titel", currentPage.Title);
}
[Fact]
public async Task Discarding_a_draft_removes_it()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var draftKey = $"discard-me-{Guid.NewGuid():N}";
await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/drafts/{draftKey}", new SaveDraftRequest(null, null, "Weg te gooien", "Inhoud"));
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/drafts/{draftKey}");
request.Headers.Add(csrf.HeaderName, csrf.Value);
using var deleteResponse = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
var getResponse = await client.GetAsync($"/api/v1/drafts/{draftKey}");
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
}
}
@@ -0,0 +1,103 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Internal links resolve by stable page identity and survive title, slug and
/// tree changes (docs/27-acceptance-matrix.md, M2-A02).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class InternalLinkResolutionTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task A_resolved_internal_link_keeps_working_after_the_target_is_renamed_and_moved()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var target = await CreatePageAsync(client, null, "PostgreSQL back-up herstellen");
var newParent = await CreatePageAsync(client, null, "Nieuwe categorie");
var sourceResponse = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(
null,
"Runbook overzicht",
"WikiPage",
"Zie [[PostgreSQL back-up herstellen|het herstelrunbook]] voor details.",
null));
var source = await sourceResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(source);
Assert.Contains($"href=\"/knowledge/{target.Id}/{target.Slug}\"", source.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("het herstelrunbook", source.RenderedHtml, StringComparison.Ordinal);
// Rename AND move the target page.
var renamed = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{target.Id}",
new UpdatePageRequest("PostgreSQL herstelprocedure (bijgewerkt)", target.Markdown, "Runbook", "Active", null),
target.ETag);
renamed.EnsureSuccessStatusCode();
var renamedTarget = await renamed.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(renamedTarget);
var moveResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{target.Id}/move", new MovePageRequest(newParent.Id, null), renamedTarget.ETag);
moveResponse.EnsureSuccessStatusCode();
// Re-fetch the SOURCE page: its stored link must still resolve to the same
// page ID even though the source page's own Markdown/authored link text was
// never touched, and the target's title/slug/parent all changed.
var sourceAfter = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{source.Id}");
Assert.NotNull(sourceAfter);
Assert.Contains($"/knowledge/{target.Id}/", sourceAfter.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("[[PostgreSQL", sourceAfter.RenderedHtml, StringComparison.Ordinal);
}
[Fact]
public async Task An_unresolved_internal_link_target_remains_visible_as_plain_bracket_text()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, "Verwijzing naar niets", "WikiPage", "Zie [[Pagina die niet bestaat]].", null));
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Contains("[[Pagina die niet bestaat]]", page.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<a ", page.RenderedHtml, StringComparison.Ordinal);
}
[Fact]
public async Task An_ambiguous_internal_link_target_also_remains_unresolved_rather_than_guessing()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await CreatePageAsync(client, null, "Overzicht");
await CreatePageAsync(client, null, "Overzicht");
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Verwijst dubbelzinnig", "WikiPage", "Zie [[Overzicht]].", null));
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Contains("[[Overzicht]]", page.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<a ", page.RenderedHtml, StringComparison.Ordinal);
}
private static async Task<PageDetail> CreatePageAsync(HttpClient client, Guid? parentId, string title)
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(parentId, title, "WikiPage", $"# {title}", null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
return page;
}
}
@@ -0,0 +1,195 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Markdown and Mermaid output is sanitized against active content
/// (docs/27-acceptance-matrix.md, M2-A03) — a corpus of realistic attack payloads
/// rendered through the real HTTP pipeline against a real database, not a unit test
/// against the sanitizer in isolation.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class MarkdownSanitizationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Preview_renders_unsaved_markdown_with_the_same_sanitization_boundary()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages/preview",
new PreviewMarkdownRequest("# Nieuw document\n\n**Veilig** <script>alert(1)</script>"));
response.EnsureSuccessStatusCode();
var preview = await response.Content.ReadFromJsonAsync<PreviewMarkdownResponse>();
Assert.NotNull(preview);
Assert.Contains("Nieuw document</h1>", preview.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("<strong>Veilig</strong>", preview.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<script", preview.RenderedHtml, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Each payload pairs with the *unescaped, live* marker that would only appear if
/// the exploit actually worked — not a bare keyword. Markdig (raw HTML disabled)
/// turns an entire literal <c>&lt;tag&gt;</c> into inert escaped text
/// (<c>&amp;lt;tag&amp;gt;</c>); quote characters inside that escaped text are
/// *not* further escaped, so a bare-keyword check like "javascript:" would still
/// match perfectly safe, non-executing text and produce a false failure. Checking
/// for the literal unescaped opening angle bracket (or, for genuine CommonMark
/// markdown-syntax links/images, the real emitted attribute) is what actually
/// distinguishes "rendered as inert text" from "rendered as live markup".
/// </summary>
public static IEnumerable<object[]> UnsafePayloads()
{
yield return ["<script>alert(1)</script>", "<script"];
yield return ["<img src=x onerror=alert(1)>", "<img"];
yield return ["<a href=\"javascript:alert(1)\">click</a>", "<a href=\"javascript:"];
yield return ["<iframe src=\"https://evil.example\"></iframe>", "<iframe"];
yield return ["<svg onload=alert(1)>", "<svg"];
yield return ["<div style=\"background:url(javascript:alert(1))\">x</div>", "<div style"];
yield return ["<img src=\"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==\">", "<img src=\"data:text/html"];
yield return ["<style>body{background:url('javascript:alert(1)')}</style>", "<style"];
yield return ["<object data=\"evil.swf\"></object>", "<object"];
yield return ["<form action=\"https://evil.example\"><input></form>", "<form"];
yield return ["<a href=\"vbscript:alert(1)\">x</a>", "<a href=\"vbscript:"];
// Genuine CommonMark link/image syntax (not raw HTML) — Markdig *does* emit a
// real <a>/<img> element for these, so the unsafe scheme must be neutralized by
// the sanitizer's AllowedSchemes allowlist, not merely by HTML-escaping.
yield return ["[click me](javascript:alert(1))", "href=\"javascript:"];
yield return ["![alt](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", "src=\"data:text/html"];
}
[Theory]
[MemberData(nameof(UnsafePayloads))]
public async Task Unsafe_markup_never_reaches_the_rendered_output_as_live_markup(string payload, string liveMarker)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, $"XSS-test {Guid.NewGuid():N}", "WikiPage", payload, null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.DoesNotContain(liveMarker, page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
// Re-fetch independently of the create response, exercising the read path too.
var fetched = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{page.Id}");
Assert.NotNull(fetched);
Assert.DoesNotContain(liveMarker, fetched.RenderedHtml, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task A_mermaid_fenced_block_renders_as_escaped_source_not_raw_script()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
const string markdown = "```mermaid\nflowchart LR\n A --> B\n```";
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Mermaid-test", "WikiPage", markdown, null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
// The exact wrapper markup is an implementation detail; what matters for
// "strict mode, client-rendered" (docs/11-markdown-and-linking.md) is that the
// raw diagram source survives intact for mermaid.js to read, and that nothing
// resembling an executable tag was produced from it.
Assert.Contains("mermaid", page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
Assert.Contains("flowchart LR", page.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<script", page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Ordinary_safe_formatting_still_renders_correctly()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
const string markdown = "# Titel\n\n**Vet** en *cursief* met een [veilige koppeling](https://example.org).\n\n- [ ] taak\n- [x] gedaan\n";
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Veilige opmaak", "WikiPage", markdown, null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Contains("<strong>Vet</strong>", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("<em>cursief</em>", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("href=\"https://example.org\"", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("rel=\"noopener noreferrer nofollow\"", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("type=\"checkbox\"", page.RenderedHtml, StringComparison.Ordinal);
}
public static IEnumerable<object[]> ManagedMarkdownCases()
{
yield return
[
"<!-- managed-source:start -->\n# Bron\n\nInhoud.\n<!-- managed-source:end -->\n\n<!-- user-notes:start -->\nEigen notitie.\n<!-- user-notes:end -->\n",
"Bron",
"Eigen notitie."
];
yield return
[
"<!-- managed-source:start --># Bron zonder regeleinde<!-- managed-source:end -->Notitie direct erna<!-- user-notes:start --><!-- user-notes:end -->",
"Bron zonder regeleinde",
"Notitie direct erna"
];
yield return
[
"<!-- managed-source:start -->\nLege notities\n<!-- managed-source:end -->\n<!-- user-notes:start -->\n\n<!-- user-notes:end -->",
"Lege notities",
string.Empty
];
}
[Theory]
[MemberData(nameof(ManagedMarkdownCases))]
public async Task Known_import_markers_remain_stored_but_never_render(
string markdown,
string expectedSource,
string expectedNotes)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, $"Marker-test {Guid.NewGuid():N}", "WikiPage", markdown, null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Equal(markdown, page.Markdown);
Assert.Contains("<!-- managed-source:start -->", page.Markdown, StringComparison.Ordinal);
Assert.DoesNotContain("managed-source", page.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("user-notes", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains(expectedSource, page.RenderedHtml, StringComparison.Ordinal);
if (expectedNotes.Length > 0)
{
Assert.Contains(expectedNotes, page.RenderedHtml, StringComparison.Ordinal);
}
}
[Fact]
public async Task Unknown_html_comments_and_active_markup_stay_inert_while_known_markers_are_hidden()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
const string markdown = "<!-- managed-source:start -->\n<!-- unknown-control -->\n<script>alert(1)</script>\n<img src=x onerror=alert(2)>\n<!-- managed-source:end -->";
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Marker security test", "WikiPage", markdown, null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.DoesNotContain("managed-source", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("unknown-control", page.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<script", page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("<img", page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,85 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Concurrent edits must produce an explicit conflict, never a silently lost
/// update (docs/27-acceptance-matrix.md, M2-A05).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class PageConcurrencyTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Publishing_with_a_stale_ETag_is_rejected_and_never_overwrites_the_newer_revision()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Conflicttest", "WikiPage", "Origineel", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var staleETag = page.ETag;
// Session A publishes successfully first, advancing the ETag.
var firstEdit = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Titel van A", "Inhoud van A", "WikiPage", "Active", null),
staleETag);
Assert.Equal(HttpStatusCode.OK, firstEdit.StatusCode);
// Session B still holds the original (now stale) ETag from before A's publish.
var secondEdit = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Titel van B", "Inhoud van B", "WikiPage", "Active", null),
staleETag);
Assert.Equal(HttpStatusCode.PreconditionFailed, secondEdit.StatusCode);
// The current server state is still A's edit, not silently overwritten by B.
var current = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{page.Id}");
Assert.NotNull(current);
Assert.Equal("Titel van A", current.Title);
}
[Fact]
public async Task Simultaneous_publishes_return_one_success_and_only_explicit_conflicts()
{
using var creator = Factory.CreateClient();
await SetupHelper.LoginAsync(creator, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
creator, "/api/v1/pages", new CreatePageRequest(null, "Gelijktijdige conflicttest", "WikiPage", "Origineel", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var clients = Enumerable.Range(0, 8).Select(_ => Factory.CreateClient()).ToArray();
try
{
await Task.WhenAll(clients.Select(client => SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword)));
var responses = await Task.WhenAll(clients.Select((client, index) =>
SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest($"Kandidaat {index}", $"Inhoud {index}", "WikiPage", "Active", null),
page.ETag)));
Assert.Single(responses, response => response.StatusCode == HttpStatusCode.OK);
Assert.Equal(7, responses.Count(response => response.StatusCode == HttpStatusCode.PreconditionFailed));
}
finally
{
foreach (var client in clients)
{
client.Dispose();
}
}
var revisions = await creator.GetFromJsonAsync<IReadOnlyList<PageRevisionSummary>>($"/api/v1/pages/{page.Id}/revisions");
Assert.NotNull(revisions);
Assert.Equal(2, revisions.Count);
}
}
@@ -0,0 +1,243 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>
/// The staged page-creation flow (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md,
/// audit finding H1): a page must never become visible half-published, with a broken
/// attachment link or a partially-attached file. These tests exercise the real
/// failure/retry scenarios the audit named explicitly, not just the happy path.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class PageCreationStagingTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private async Task<HttpClient> AuthenticatedClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
private static async Task<Guid> BeginSessionAsync(HttpClient client, Guid? parentPageId = null)
{
var response = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/pages/staged", new BeginPageCreationSessionRequest(parentPageId));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var session = await response.Content.ReadFromJsonAsync<PageCreationSessionResponse>();
Assert.NotNull(session);
return session.SessionId;
}
[Fact]
public async Task A_page_created_with_attachments_is_published_atomically_with_every_attachment_already_linked()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "diagram.png", "image/png");
Assert.Equal(HttpStatusCode.OK, upload.StatusCode);
var attachment = await upload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
// The URL is real the moment it is uploaded — no placeholder token, nothing to
// rewrite server-side later (a deliberately stronger alternative to the
// preferred "server-side token replacement" direction; see the ADR).
var markdown = $"# Diagram\n\n![diagram]({{content}})".Replace("{content}", $"/api/v1/attachments/{attachment.Id}/content");
var commitResponse = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Pagina met bijlage", "WikiPage", markdown, null));
Assert.Equal(HttpStatusCode.OK, commitResponse.StatusCode);
var page = await commitResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Contains($"/api/v1/attachments/{attachment.Id}/content", page.Markdown);
// The attachment now belongs to the real page — visible through the ordinary
// page-attachments listing, not just independently reachable by ID.
var listed = await client.GetFromJsonAsync<AttachmentListResponse>($"/api/v1/pages/{page.Id}/attachments");
Assert.NotNull(listed);
Assert.Contains(listed.Items, item => item.Id == attachment.Id);
var content = await client.GetAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
}
[Fact]
public async Task A_middle_upload_failing_leaves_earlier_successful_uploads_intact_and_the_page_still_unpublished()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
var first = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "eerste.png", "image/png");
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var firstAttachment = await first.Content.ReadFromJsonAsync<AttachmentSummary>();
// An executable signature is rejected regardless of declared type — the
// "middle upload fails" scenario.
var executableBytes = new byte[] { 0x4D, 0x5A, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00 };
var failing = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", executableBytes, "kwaadaardig.exe", "image/png");
Assert.Equal(HttpStatusCode.UnprocessableEntity, failing.StatusCode);
// The first, successful upload is unaffected and still independently valid —
// the caller can retry the failed one or continue composing; nothing was rolled
// back, and nothing has been published yet either.
var contentStillThere = await client.GetAsync($"/api/v1/attachments/{firstAttachment!.Id}/content");
Assert.Equal(HttpStatusCode.OK, contentStillThere.StatusCode);
var third = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "tweede.png", "image/png");
Assert.Equal(HttpStatusCode.OK, third.StatusCode);
var commit = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Pagina na gedeeltelijke fout", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.OK, commit.StatusCode);
}
[Fact]
public async Task A_discarded_session_removes_its_uploaded_but_never_published_attachments()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "weggegooid.png", "image/png");
var attachment = await upload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
var discard = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/discard", new { });
Assert.Equal(HttpStatusCode.OK, discard.StatusCode);
var discarded = await discard.Content.ReadFromJsonAsync<DiscardPageCreationSessionResponse>();
Assert.NotNull(discarded);
Assert.Equal(1, discarded.AttachmentsRemoved);
// No half-published, no orphan left silently reachable — the reference itself
// is gone (not merely "unlinked"), matching the same orphan-removal shape
// already proven for LeafWiki import rollback.
var content = await client.GetAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal(HttpStatusCode.NotFound, content.StatusCode);
// Discard is idempotent — a retried discard (e.g. after a lost response) is a
// safe no-op, not an error.
var retryDiscard = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/discard", new { });
Assert.Equal(HttpStatusCode.OK, retryDiscard.StatusCode);
}
[Fact]
public async Task Committing_an_already_discarded_session_fails_and_cannot_publish_a_second_time()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/discard", new { });
var commit = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Mag niet publiceren", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.Conflict, commit.StatusCode);
}
[Fact]
public async Task Committing_an_already_committed_session_a_second_time_never_creates_a_second_page()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
var request = new CommitPageCreationSessionRequest("Slechts eenmaal", "WikiPage", "# Inhoud", null);
var first = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/commit", request);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var second = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/commit", request);
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.Single(roots!.Items, item => item.Title == "Slechts eenmaal");
}
[Fact]
public async Task Retrying_a_commit_with_the_same_Idempotency_Key_replays_the_original_page_instead_of_creating_a_duplicate()
{
using var client = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(client);
var request = new CommitPageCreationSessionRequest("Idempotente publicatie", "WikiPage", "# Inhoud", null);
var idempotencyKey = Guid.NewGuid().ToString();
var first = await PostWithIdempotencyKeyAsync(client, $"/api/v1/pages/staged/{sessionId}/commit", request, idempotencyKey);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var firstBody = await first.Content.ReadFromJsonAsync<PageDetail>();
var second = await PostWithIdempotencyKeyAsync(client, $"/api/v1/pages/staged/{sessionId}/commit", request, idempotencyKey);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
var secondBody = await second.Content.ReadFromJsonAsync<PageDetail>();
Assert.Equal(firstBody!.Id, secondBody!.Id);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.Single(roots!.Items, item => item.Title == "Idempotente publicatie");
}
[Fact]
public async Task Uploading_into_an_unknown_or_already_closed_session_fails_clearly()
{
using var client = await AuthenticatedClientAsync();
var unknown = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{Guid.NewGuid()}/attachments", PngBytes, "wees.png", "image/png");
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
var sessionId = await BeginSessionAsync(client);
await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/pages/staged/{sessionId}/discard", new { });
var closed = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "te-laat.png", "image/png");
Assert.Equal(HttpStatusCode.NotFound, closed.StatusCode);
}
[Fact]
public async Task A_reader_cannot_begin_upload_commit_or_discard_a_staging_session()
{
using var adminClient = await AuthenticatedClientAsync();
var sessionId = await BeginSessionAsync(adminClient);
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/admin/users",
new CreateUserRequest("Lezer", "lezer-staging@itworx.test", "reader-strong-password", "Reader"));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var readerClient = Factory.CreateClient();
await SetupHelper.LoginAsync(readerClient, "lezer-staging@itworx.test", "reader-strong-password");
var begin = await SetupHelper.PostWithCsrfAsync(readerClient, "/api/v1/pages/staged", new BeginPageCreationSessionRequest(null));
Assert.Equal(HttpStatusCode.Forbidden, begin.StatusCode);
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
readerClient, $"/api/v1/pages/staged/{sessionId}/attachments", PngBytes, "onbevoegd.png", "image/png");
Assert.Equal(HttpStatusCode.Forbidden, upload.StatusCode);
var commit = await SetupHelper.PostWithCsrfAsync(
readerClient, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Onbevoegd", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.Forbidden, commit.StatusCode);
var discard = await SetupHelper.PostWithCsrfAsync(readerClient, $"/api/v1/pages/staged/{sessionId}/discard", new { });
Assert.Equal(HttpStatusCode.Forbidden, discard.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 = System.Net.Http.Json.JsonContent.Create(body) };
request.Headers.Add(csrf.HeaderName, csrf.Value);
request.Headers.Add("Idempotency-Key", idempotencyKey);
return await client.SendAsync(request);
}
}
@@ -0,0 +1,196 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
[Collection(PostgresTestGroup.Name)]
public sealed class PageCrudTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private async Task<HttpClient> AuthenticatedClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
[Fact]
public async Task A_root_page_can_be_created_read_and_appears_among_the_roots()
{
using var client = await AuthenticatedClientAsync();
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Kennisbank startpunt", "WikiPage", "# Welkom", null));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(created);
Assert.Equal("Kennisbank startpunt", created.Title);
Assert.Equal("kennisbank-startpunt", created.Slug);
Assert.False(string.IsNullOrWhiteSpace(created.ETag));
var getResponse = await client.GetAsync($"/api/v1/pages/{created.Id}");
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var fetched = await getResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(fetched);
// Markdig's advanced extensions add deterministic heading anchor IDs
// (docs/11-markdown-and-linking.md — "Heading IDs are deterministic within a
// rendered revision").
Assert.Equal("<h1 id=\"welkom\">Welkom</h1>\n", fetched.RenderedHtml);
Assert.Single(fetched.Breadcrumb);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
Assert.Contains(roots.Items, item => item.Id == created.Id);
}
[Fact]
public async Task A_child_page_reports_a_breadcrumb_and_makes_its_parent_report_children()
{
using var client = await AuthenticatedClientAsync();
var parent = await CreatePageAsync(client, null, "Ouderpagina");
var child = await CreatePageAsync(client, parent.Id, "Kindpagina");
var childDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{child.Id}");
Assert.NotNull(childDetail);
Assert.Equal(2, childDetail.Breadcrumb.Count);
Assert.Equal(parent.Id, childDetail.Breadcrumb[0].Id);
Assert.Equal(child.Id, childDetail.Breadcrumb[1].Id);
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(children);
Assert.Single(children.Items);
Assert.Equal(child.Id, children.Items[0].Id);
}
[Fact]
public async Task Editing_a_page_with_the_current_ETag_succeeds_and_returns_a_new_ETag()
{
using var client = await AuthenticatedClientAsync();
var page = await CreatePageAsync(client, null, "Bewerkbare pagina");
var updateResponse = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Bewerkte titel", "Nieuwe inhoud", "WikiPage", "Active", "Testbewerking"),
page.ETag);
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updated = await updateResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(updated);
Assert.Equal("Bewerkte titel", updated.Title);
Assert.NotEqual(page.ETag, updated.ETag);
Assert.Equal(2, updated.CurrentRevisionNumber);
}
[Fact]
public async Task Editing_a_page_without_an_if_match_header_is_rejected()
{
using var client = await AuthenticatedClientAsync();
var page = await CreatePageAsync(client, null, "Zonder If-Match");
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/pages/{page.Id}")
{
Content = JsonContent.Create(new UpdatePageRequest("Titel", "Inhoud", "WikiPage", "Active", null))
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.PreconditionRequired, response.StatusCode);
}
[Fact]
public async Task Trashing_a_page_removes_it_from_the_children_listing_and_restoring_brings_it_back()
{
using var client = await AuthenticatedClientAsync();
var parent = await CreatePageAsync(client, null, "Prullenbaktest ouder");
var page = await CreatePageAsync(client, parent.Id, "Weg te gooien pagina");
var trashResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag);
Assert.Equal(HttpStatusCode.OK, trashResponse.StatusCode);
var childrenAfterTrash = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(childrenAfterTrash);
Assert.Empty(childrenAfterTrash.Items);
var trashedDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{page.Id}");
Assert.NotNull(trashedDetail);
Assert.NotNull(trashedDetail.TrashedAt);
var restoreResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{page.Id}/restore", (object?)null, trashedDetail.ETag);
Assert.Equal(HttpStatusCode.OK, restoreResponse.StatusCode);
var childrenAfterRestore = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(childrenAfterRestore);
Assert.Single(childrenAfterRestore.Items);
}
[Fact]
public async Task A_trashed_page_cannot_be_edited_until_restored()
{
using var client = await AuthenticatedClientAsync();
var page = await CreatePageAsync(client, null, "Te bewerken na prullenbak");
await SetupHelper.PostWithCsrfAndIfMatchAsync(client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag);
var trashedDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{page.Id}");
Assert.NotNull(trashedDetail);
var editResponse = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Nieuwe titel", "Nieuwe inhoud", "WikiPage", "Active", null),
trashedDetail.ETag);
Assert.Equal(HttpStatusCode.Conflict, editResponse.StatusCode);
}
[Fact]
public async Task Moving_a_page_under_its_own_descendant_is_rejected_as_a_cycle()
{
using var client = await AuthenticatedClientAsync();
var parent = await CreatePageAsync(client, null, "Cyclustest ouder");
var child = await CreatePageAsync(client, parent.Id, "Cyclustest kind");
var moveResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{parent.Id}/move", new MovePageRequest(child.Id, null), parent.ETag);
Assert.Equal(HttpStatusCode.Conflict, moveResponse.StatusCode);
}
[Fact]
public async Task Reordering_among_siblings_changes_their_listed_order()
{
using var client = await AuthenticatedClientAsync();
var parent = await CreatePageAsync(client, null, "Volgordetest ouder");
var first = await CreatePageAsync(client, parent.Id, "Eerste");
var second = await CreatePageAsync(client, parent.Id, "Tweede");
var beforeChildren = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(beforeChildren);
Assert.Equal([first.Id, second.Id], beforeChildren.Items.Select(item => item.Id).ToArray());
// Move "first" to come after "second".
var moveResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{first.Id}/move", new MovePageRequest(parent.Id, second.Id), first.ETag);
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
var afterChildren = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{parent.Id}/children");
Assert.NotNull(afterChildren);
Assert.Equal([second.Id, first.Id], afterChildren.Items.Select(item => item.Id).ToArray());
}
private static async Task<PageDetail> CreatePageAsync(HttpClient client, Guid? parentId, string title)
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(parentId, title, "WikiPage", $"# {title}", null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
return page;
}
}
@@ -0,0 +1,101 @@
using System.Diagnostics;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>A 10,000-node synthetic hierarchy remains navigable without loading the
/// entire tree into the browser (docs/27-acceptance-matrix.md, M2-A07): the API must
/// serve one bounded page of children quickly, regardless of how many siblings exist
/// in total.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class PageTreeScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int ChildCount = 10_000;
[Fact]
public async Task Fetching_one_page_of_children_under_a_10000_child_node_stays_fast_and_bounded()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Grote categorie", "WikiPage", "Root", null));
var parent = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(parent);
await SeedChildrenDirectlyAsync(parent.Id);
var stopwatch = Stopwatch.StartNew();
var firstPage = await client.GetFromJsonAsync<PageChildrenResponse>(
$"/api/v1/pages/{parent.Id}/children?limit=50");
stopwatch.Stop();
Assert.NotNull(firstPage);
Assert.Equal(50, firstPage.Items.Count);
Assert.False(string.IsNullOrEmpty(firstPage.NextCursor));
// Generous bound for a CI/dev machine: the point is "bounded and fast", not a
// precise performance benchmark (that belongs to the M8 release scale suite).
Assert.True(
stopwatch.Elapsed < TimeSpan.FromSeconds(5),
$"Fetching one bounded page of children took {stopwatch.Elapsed}, expected it to stay well under 5s regardless of the {ChildCount} total children.");
var secondPage = await client.GetFromJsonAsync<PageChildrenResponse>(
$"/api/v1/pages/{parent.Id}/children?limit=50&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
Assert.NotNull(secondPage);
Assert.Equal(50, secondPage.Items.Count);
Assert.Empty(firstPage.Items.Select(item => item.Id).Intersect(secondPage.Items.Select(item => item.Id)));
}
private async Task SeedChildrenDirectlyAsync(Guid parentId)
{
// Seeded directly through the DbContext rather than 10,000 HTTP calls — this
// test is about proving the *read* path stays bounded at scale, not about
// exercising page creation (already covered by PageCrudTests).
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var parent = await db.Pages.FindAsync(parentId);
Assert.NotNull(parent);
var workspaceId = parent.WorkspaceId;
var actorId = parent.CreatedBy ?? Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var sortKey = SortKey.First();
var batch = new List<Page>(1000);
for (var i = 0; i < ChildCount; i++)
{
var page = Page.Create(
workspaceId,
parentId,
sortKey,
PageTitle.Create($"Kindpagina {i:D5}"),
DocumentType.WikiPage,
"Inhoud",
actorId,
now);
batch.Add(page);
sortKey = SortKey.Next(sortKey);
if (batch.Count == 1000)
{
db.Pages.AddRange(batch);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
batch.Clear();
}
}
if (batch.Count > 0)
{
db.Pages.AddRange(batch);
await db.SaveChangesAsync();
}
}
}
@@ -0,0 +1,304 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>
/// The RAG publication manifest (docs/32-rag-publication-convention.md): a single
/// narrow read-only endpoint for RAGcore's n8n sync, entirely driven by the
/// <c>rag:publish</c>/<c>rag:space:</c>/<c>rag:allow-deprecated</c>/<c>locale:</c> tag
/// convention rather than a new editorial model.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class RagPublicationManifestTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static async Task<PageDetail> CreatePageAsync(
HttpClient client, string title, IReadOnlyList<string>? tags = null, string markdown = "Inhoud.")
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, title, "WikiPage", markdown, null, tags));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
return page;
}
/// <summary>Publishes a page to <c>Active</c> — every page starts as a <c>Draft</c>
/// "concept" at creation (<c>Page.Create</c>), so live-content assertions must
/// explicitly advance it, exactly like an editor publishing from the UI would.
/// </summary>
private static async Task<PageDetail> PublishAsync(HttpClient client, PageDetail page)
{
var response = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest(page.Title, page.Markdown, page.DocumentType, "Active", "Gepubliceerd.", page.Tags),
page.ETag);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var updated = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(updated);
return updated;
}
[Fact]
public async Task Only_pages_tagged_rag_publish_ever_appear_in_the_manifest()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var tagged = await CreatePageAsync(client, $"Gepubliceerd {Guid.NewGuid():N}", [RagPublicationTags.Publish]);
var untagged = await CreatePageAsync(client, $"Niet gepubliceerd {Guid.NewGuid():N}");
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
Assert.Contains(manifest.Items, item => item.ContentId == tagged.Id);
Assert.DoesNotContain(manifest.Items, item => item.ContentId == untagged.Id);
}
[Fact]
public async Task A_draft_concept_comes_back_retired_without_leaking_its_markdown_or_checksum()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
// Deliberately never published — stays Draft ("concept"), matching Page.Create's
// default status.
var draft = await CreatePageAsync(
client, $"Concept {Guid.NewGuid():N}", [RagPublicationTags.Publish], "Geheime conceptinhoud.");
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
var item = Assert.Single(manifest.Items, entry => entry.ContentId == draft.Id);
Assert.True(item.Retired);
Assert.Equal("draft", item.RetiredReason);
Assert.Null(item.Markdown);
Assert.Null(item.Checksum);
}
[Fact]
public async Task An_active_published_page_comes_back_live_with_markdown_and_a_matching_checksum()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var created = await CreatePageAsync(
client, $"Live {Guid.NewGuid():N}", [RagPublicationTags.Publish], "# Live inhoud");
var published = await PublishAsync(client, created);
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
var item = Assert.Single(manifest.Items, entry => entry.ContentId == published.Id);
Assert.False(item.Retired);
Assert.Null(item.RetiredReason);
Assert.Equal("# Live inhoud", item.Markdown);
Assert.NotNull(item.Checksum);
Assert.Equal(published.CurrentRevisionNumber, item.Revision);
Assert.Contains($"/knowledge/{published.Id}/{published.Slug}", item.CanonicalUrl, StringComparison.Ordinal);
}
[Fact]
public async Task A_trashed_page_comes_back_retired_without_markdown_so_the_sync_can_withdraw_it()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var created = await CreatePageAsync(client, $"Te verwijderen {Guid.NewGuid():N}", [RagPublicationTags.Publish]);
var published = await PublishAsync(client, created);
await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{published.Id}/trash", (object?)null, published.ETag);
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
var item = Assert.Single(manifest.Items, entry => entry.ContentId == published.Id);
Assert.True(item.Retired);
Assert.Equal("trashed", item.RetiredReason);
Assert.Null(item.Markdown);
Assert.Null(item.Checksum);
}
[Fact]
public async Task A_deprecated_page_without_the_allow_flag_is_retired_but_with_the_flag_it_stays_live()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var withoutFlag = await PublishAsync(
client, await CreatePageAsync(client, $"Verouderd {Guid.NewGuid():N}", [RagPublicationTags.Publish]));
var deprecatedWithoutFlag = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{withoutFlag.Id}",
new UpdatePageRequest(withoutFlag.Title, withoutFlag.Markdown, withoutFlag.DocumentType, "Deprecated", "Verouderd.", withoutFlag.Tags),
withoutFlag.ETag);
Assert.Equal(HttpStatusCode.OK, deprecatedWithoutFlag.StatusCode);
var withFlagTags = new[] { RagPublicationTags.Publish, RagPublicationTags.AllowDeprecated };
var withFlag = await PublishAsync(
client, await CreatePageAsync(client, $"Verouderd maar toegestaan {Guid.NewGuid():N}", withFlagTags));
var deprecatedWithFlag = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{withFlag.Id}",
new UpdatePageRequest(withFlag.Title, withFlag.Markdown, withFlag.DocumentType, "Deprecated", "Verouderd, maar nog toegestaan.", withFlagTags),
withFlag.ETag);
Assert.Equal(HttpStatusCode.OK, deprecatedWithFlag.StatusCode);
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
var retiredItem = Assert.Single(manifest.Items, entry => entry.ContentId == withoutFlag.Id);
Assert.True(retiredItem.Retired);
Assert.Equal("deprecated", retiredItem.RetiredReason);
Assert.Null(retiredItem.Markdown);
var liveItem = Assert.Single(manifest.Items, entry => entry.ContentId == withFlag.Id);
Assert.False(liveItem.Retired);
Assert.NotNull(liveItem.Markdown);
}
[Fact]
public async Task Locale_falls_back_to_the_configured_primary_locale_and_never_defaults_to_english()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var undeclared = await PublishAsync(
client, await CreatePageAsync(client, $"Geen locale {Guid.NewGuid():N}", [RagPublicationTags.Publish]));
var declaredTags = new[] { RagPublicationTags.Publish, "locale:en-US" };
var declared = await PublishAsync(
client, await CreatePageAsync(client, $"Expliciete locale {Guid.NewGuid():N}", declaredTags));
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?limit=200");
Assert.NotNull(manifest);
var undeclaredItem = Assert.Single(manifest.Items, entry => entry.ContentId == undeclared.Id);
// appsettings.json's KnowledgeHub:PrimaryLocale — never a silent "en".
Assert.Equal("nl-BE", undeclaredItem.Locale);
var declaredItem = Assert.Single(manifest.Items, entry => entry.ContentId == declared.Id);
Assert.Equal("en-US", declaredItem.Locale);
}
[Fact]
public async Task The_space_filter_only_returns_pages_carrying_that_exact_space_tag()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var fleetOps = await PublishAsync(
client,
await CreatePageAsync(
client, $"Fleet-ops {Guid.NewGuid():N}", [RagPublicationTags.Publish, "rag:space:fleet-ops"]));
var otherSpace = await PublishAsync(
client,
await CreatePageAsync(
client, $"Andere space {Guid.NewGuid():N}", [RagPublicationTags.Publish, "rag:space:other"]));
var manifest = await client.GetFromJsonAsync<RagPublicationManifestResponse>(
"/api/v1/rag/publication-manifest?space=fleet-ops&limit=200");
Assert.NotNull(manifest);
Assert.Contains(manifest.Items, item => item.ContentId == fleetOps.Id);
Assert.DoesNotContain(manifest.Items, item => item.ContentId == otherSpace.Id);
}
[Fact]
public async Task An_invalid_space_parameter_is_rejected_rather_than_smuggled_into_the_array_filter()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await client.GetAsync("/api/v1/rag/publication-manifest?space=Not_Valid!");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task The_manifest_requires_authentication()
{
using var client = Factory.CreateClient();
var response = await client.GetAsync("/api/v1/rag/publication-manifest");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task A_bearer_token_with_only_pages_read_can_call_the_manifest()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
var createServiceAccount = await SetupHelper.PostWithCsrfAsync(
adminClient,
"/api/v1/service-accounts",
new CreateServiceAccountRequest("RAGcore Knowledge Sync (test)", "Alleen-lezen synctoken.", [Scopes.PagesRead]));
Assert.Equal(HttpStatusCode.OK, createServiceAccount.StatusCode);
var created = await createServiceAccount.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(created);
using var bearerClient = Factory.CreateClient();
bearerClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", created.RawToken);
var response = await bearerClient.GetAsync("/api/v1/rag/publication-manifest");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Cursor_pagination_is_deterministic_and_covers_every_matching_page_without_duplicates()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var created = new List<Guid>();
for (var i = 0; i < 7; i++)
{
var page = await CreatePageAsync(client, $"Manifestreeks {i:D2} {Guid.NewGuid():N}", [RagPublicationTags.Publish]);
created.Add(page.Id);
}
var seen = new HashSet<Guid>();
string? cursor = null;
var pages = 0;
do
{
var url = cursor is null
? "/api/v1/rag/publication-manifest?limit=3"
: $"/api/v1/rag/publication-manifest?limit=3&cursor={Uri.EscapeDataString(cursor)}";
var response = await client.GetFromJsonAsync<RagPublicationManifestResponse>(url);
Assert.NotNull(response);
foreach (var item in response.Items)
{
Assert.True(seen.Add(item.ContentId), $"Item {item.ContentId} was returned on more than one page.");
}
cursor = response.NextCursor;
pages++;
Assert.True(pages < 50, "Pagination did not terminate within a reasonable number of pages.");
} while (cursor is not null);
foreach (var id in created)
{
Assert.Contains(id, seen);
}
}
}
@@ -0,0 +1,107 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Every published mutation creates a revision atomically (M3-A01), and a user
/// can compare/restore past revisions without losing the current version (M3-A02).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class RevisionsTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Publishing_creates_a_new_revision_every_time()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Revisiepagina", "WikiPage", "Versie 1", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Revisiepagina", "Versie 2", "WikiPage", "Active", "Naar versie 2"),
page.ETag);
var revisions = await client.GetFromJsonAsync<List<PageRevisionSummary>>($"/api/v1/pages/{page.Id}/revisions");
Assert.NotNull(revisions);
Assert.Equal(2, revisions.Count);
// Most recent first.
Assert.Equal(2, revisions[0].RevisionNumber);
Assert.Equal(1, revisions[1].RevisionNumber);
Assert.Equal("Naar versie 2", revisions[0].ChangeSummary);
}
[Fact]
public async Task Restoring_a_past_revision_creates_a_new_revision_without_losing_history()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Titel A", "WikiPage", "Inhoud A", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var afterFirstEdit = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Titel B", "Inhoud B", "WikiPage", "Active", null),
page.ETag);
var pageAfterEdit = await afterFirstEdit.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(pageAfterEdit);
Assert.Equal("Titel B", pageAfterEdit.Title);
// Fetch the original revision's content before restoring it.
var originalRevision = await client.GetFromJsonAsync<PageRevisionDetailResponse>($"/api/v1/pages/{page.Id}/revisions/1");
Assert.NotNull(originalRevision);
Assert.Equal("Titel A", originalRevision.Title);
Assert.Equal("Inhoud A", originalRevision.Markdown);
var restoreResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{page.Id}/revisions/1/restore", (object?)null, pageAfterEdit.ETag);
Assert.Equal(HttpStatusCode.OK, restoreResponse.StatusCode);
var restored = await restoreResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(restored);
Assert.Equal("Titel A", restored.Title);
Assert.Equal("Inhoud A", restored.Markdown);
Assert.Equal(3, restored.CurrentRevisionNumber);
// History is never rewritten: all three revisions remain, including the
// "Titel B" state that briefly existed as the current version.
var revisions = await client.GetFromJsonAsync<List<PageRevisionSummary>>($"/api/v1/pages/{page.Id}/revisions");
Assert.NotNull(revisions);
Assert.Equal(3, revisions.Count);
Assert.Contains(revisions, revision => revision.RevisionNumber == 2 && revision.Title == "Titel B");
}
[Fact]
public async Task Restoring_against_a_stale_etag_is_rejected_as_a_conflict()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Titel A", "WikiPage", "Inhoud A", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{page.Id}",
new UpdatePageRequest("Titel B", "Inhoud B", "WikiPage", "Active", null),
page.ETag);
// page.ETag is now stale (it was captured before the edit above).
var restoreResponse = await SetupHelper.PostWithCsrfAndIfMatchAsync(
client, $"/api/v1/pages/{page.Id}/revisions/1/restore", (object?)null, page.ETag);
Assert.Equal(HttpStatusCode.PreconditionFailed, restoreResponse.StatusCode);
}
}
@@ -0,0 +1,435 @@
using System.Diagnostics;
using System.Globalization;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>
/// The full-scale proof docs/10-search-and-indexing.md's "Scale verification" section
/// describes and ADR-012 decision 7 deferred to M8: 100,000 current pages and
/// 1,000,000 revisions, with captured p50/p95/p99 latency, query plans, index sizes,
/// result correctness and a no-leak assertion. Tagged "ReleaseScale" and excluded from
/// the routine test run (scripts/verify.mjs filters it out by default) — seeding and
/// measuring at this scale is a deliberate, occasional release-gate exercise, not a
/// per-commit check. Run explicitly via:
/// <c>dotnet test --filter "Category=ReleaseScale"</c>.
/// Data is generated directly in PostgreSQL via <c>generate_series</c> (a single
/// server-side INSERT...SELECT per table), not one round trip per row — the only way
/// 1.1 million rows seed in a runtime proportionate to a release-gate step rather than
/// a dedicated benchmarking rig.
/// </summary>
[Collection(PostgresTestGroup.Name)]
[Trait("Category", "ReleaseScale")]
public sealed class SearchFullScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int PageCount = 100_000;
private const int RevisionsPerPage = 10; // 100,000 * 10 = 1,000,000 revisions.
private const int SampleCount = 30;
[Fact]
public async Task Search_and_tree_reads_stay_correct_and_bounded_at_the_documented_full_scale()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var report = new StringBuilder();
report.AppendLine(CultureInfo.InvariantCulture, $"M8 full-scale search/tree report — {DateTimeOffset.UtcNow:O}");
report.AppendLine(CultureInfo.InvariantCulture, $"Target: {PageCount:N0} current pages, {PageCount * RevisionsPerPage:N0} revisions (docs/10-search-and-indexing.md).");
Guid workspaceId;
Guid needleId;
var seedStopwatch = Stopwatch.StartNew();
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
workspaceId = workspace.Id;
var connectionOptions = scope.ServiceProvider.GetRequiredService<ItWorx.KnowledgeHub.Infrastructure.Persistence.DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
needleId = await SeedPagesAsync(connection, workspaceId);
await SeedRevisionsAsync(connection, workspaceId);
// A deliberately separate, inaccessible-from-workspace-1 workspace with a
// rare term in its title — proves no cross-workspace leak at scale
// (docs/10-search-and-indexing.md "Tests must include private pages whose
// titles and rare terms would reveal their existence").
await SeedOtherWorkspaceLeakCheckAsync(connection);
// The release measurement represents steady-state reads after an import,
// not PostgreSQL's concurrent statistics, autovacuum or checkpoint work
// immediately after 1.1 million bulk inserts. Make that boundary explicit
// so host IO timing cannot decide which of the 30 samples cross the
// unchanged 1s p95 gate. This does not warm the measured HTTP queries or
// weaken their dataset, sample count or latency bound.
await PrepareReadMeasurementAsync(connection);
}
seedStopwatch.Stop();
report.AppendLine(CultureInfo.InvariantCulture, $"Seed time: {seedStopwatch.Elapsed}.");
using (var scope = Factory.Services.CreateScope())
{
// These counts validate the completed 1.1-million-row seed; they are not
// part of the measured HTTP read latency below. Use the same explicit
// release-scale command timeout as the bulk seed/ANALYZE operations.
// EF's normal 30-second application timeout can expire while Docker
// Desktop is still flushing the just-seeded relation, which makes host IO
// timing fail setup before any actual search performance is measured.
var connectionOptions = scope.ServiceProvider.GetRequiredService<ItWorx.KnowledgeHub.Infrastructure.Persistence.DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
await using var countCommand = new NpgsqlCommand(
"SELECT (SELECT count(*) FROM pages), (SELECT count(*) FROM page_revisions)",
connection)
{
CommandTimeout = 300
};
await using var reader = await countCommand.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync(), "Release-scale row-count query returned no row.");
var actualPages = reader.GetInt64(0);
var actualRevisions = reader.GetInt64(1);
report.AppendLine(CultureInfo.InvariantCulture, $"Actual seeded rows: pages={actualPages:N0}, page_revisions={actualRevisions:N0}.");
Assert.True(actualPages >= PageCount, $"Expected at least {PageCount:N0} pages, found {actualPages:N0}.");
Assert.True(actualRevisions >= PageCount * (long)RevisionsPerPage, $"Expected at least {PageCount * RevisionsPerPage:N0} revisions, found {actualRevisions:N0}.");
}
// --- correctness: the needle is found, and nothing from the other workspace leaks ---
var needleResponse = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=xyzzyplugh");
Assert.NotNull(needleResponse);
Assert.Contains(needleResponse.Items, item => item.Id == needleId);
var leakResponse = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=quibblefrastication");
Assert.NotNull(leakResponse);
Assert.Empty(leakResponse.Items);
report.AppendLine("Correctness: needle term found; other-workspace rare term does not leak. PASS.");
var selectiveTagResponse = await client.GetFromJsonAsync<SearchResponse>(
"/api/v1/search?q=documentatie&tag=selective-scale-test");
Assert.NotNull(selectiveTagResponse);
Assert.NotEmpty(selectiveTagResponse.Items);
Assert.All(selectiveTagResponse.Items, item => Assert.Contains("selective-scale-test", item.Tags));
report.AppendLine("Correctness: selective tag returns only tagged pages. PASS.");
// --- latency: p50/p95/p99 across representative query shapes ---
report.AppendLine();
report.AppendLine("Latency (ms), 30 samples per query shape:");
var measurements = new[]
{
await MeasureAsync(client, report, "common term (\"documentatie\")", "/api/v1/search?q=documentatie"),
await MeasureAsync(client, report, "rare needle term", "/api/v1/search?q=xyzzyplugh"),
await MeasureAsync(client, report, "typo-tolerant title (trigram)", "/api/v1/search?q=documentaite"),
await MeasureAsync(client, report, "tag filter (all pages carry the tag)", "/api/v1/search?q=documentatie&tag=scale-test"),
await MeasureAsync(client, report, "selective tag filter", "/api/v1/search?q=documentatie&tag=selective-scale-test")
};
// --- query plan + index sizes ---
using (var scope = Factory.Services.CreateScope())
{
var connectionOptions = scope.ServiceProvider.GetRequiredService<ItWorx.KnowledgeHub.Infrastructure.Persistence.DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
report.AppendLine();
report.AppendLine("Query plan (EXPLAIN ANALYZE) for the common-term search:");
report.AppendLine(await ExplainAsync(connection, workspaceId, "documentatie"));
report.AppendLine();
report.AppendLine("Query plan (EXPLAIN ANALYZE) for the typo-only trigram search:");
report.AppendLine(await ExplainFuzzyAsync(connection, workspaceId, "documentaite"));
report.AppendLine();
report.AppendLine("Query plan (EXPLAIN ANALYZE) for the broad tag-filter candidate scan:");
report.AppendLine(await ExplainTagFilterAsync(connection, workspaceId, "documentatie", "scale-test"));
report.AppendLine();
report.AppendLine("Query plan (EXPLAIN ANALYZE) for the selective tag-filter candidate scan:");
var selectiveTagPlan = await ExplainTagFilterAsync(connection, workspaceId, "documentatie", "selective-scale-test");
report.AppendLine(selectiveTagPlan);
Assert.Contains("ix_pages_tags", selectiveTagPlan, StringComparison.Ordinal);
report.AppendLine();
report.AppendLine("Index sizes:");
report.AppendLine(await IndexSizesAsync(connection, "pages"));
}
var reportPath = System.IO.Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "artifacts", "m8-scale-report.txt");
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(reportPath)!);
await System.IO.File.WriteAllTextAsync(reportPath, report.ToString());
foreach (var measurement in measurements)
{
// Generous release-gate bound — proving the indexed query stays
// sub-second at true documented scale, not a tight micro-benchmark.
Assert.True(
measurement.P95 < 1000,
$"{measurement.Label}: p95 was {measurement.P95:F1}ms, expected well under 1000ms at {PageCount:N0}-page scale.");
}
}
private static async Task<Guid> SeedPagesAsync(NpgsqlConnection connection, Guid workspaceId)
{
var needleId = Guid.NewGuid();
const string sql = """
INSERT INTO pages (
id, workspace_id, parent_page_id, sort_key, title, slug, markdown,
document_type, document_status, current_revision_number, content_hash,
created_by, updated_by, created_at, updated_at, tags)
SELECT
CASE WHEN n = 1 THEN @needleId::uuid ELSE gen_random_uuid() END,
@workspaceId,
NULL,
lpad(n::text, 12, '0'),
CASE WHEN n = 1 THEN 'Uniek vindbaar trefwoord xyzzyplugh'
ELSE 'Testpagina ' || n || ' ' ||
(CASE WHEN n % 2 = 0 THEN 'documentatie en projecten' ELSE 'technical procedures and code' END)
END,
'schaal-pagina-' || n,
CASE WHEN n = 1
THEN 'Deze pagina bevat het unieke trefwoord xyzzyplugh nergens anders vermeld.'
ELSE repeat(
'Gewone opvultekst met Nederlandse en Engelse woorden door elkaar over documentatie, projecten, code en procedures. ',
5)
END,
0,
1,
1,
md5(n::text),
NULL,
NULL,
now() - (n || ' seconds')::interval,
now(),
CASE WHEN n % 1000 = 0
THEN ARRAY['scale-test', 'selective-scale-test']::text[]
ELSE ARRAY['scale-test']::text[]
END
FROM generate_series(1, @pageCount) AS n
""";
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 300 };
command.Parameters.AddWithValue("needleId", needleId);
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("pageCount", PageCount);
await command.ExecuteNonQueryAsync();
return needleId;
}
private static async Task SeedRevisionsAsync(NpgsqlConnection connection, Guid workspaceId)
{
const string sql = """
INSERT INTO page_revisions (
id, page_id, revision_number, title, slug, markdown, parent_page_id,
sort_key, document_type, document_status, content_hash, authored_by,
change_summary, created_at)
SELECT
gen_random_uuid(), p.id, r.rn, p.title, p.slug, p.markdown, p.parent_page_id,
p.sort_key, p.document_type, p.document_status, p.content_hash, NULL, NULL,
p.created_at + (r.rn || ' minutes')::interval
FROM pages p
CROSS JOIN generate_series(1, @revisionsPerPage) AS r(rn)
WHERE p.workspace_id = @workspaceId
""";
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 300 };
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("revisionsPerPage", RevisionsPerPage);
await command.ExecuteNonQueryAsync();
}
private static async Task SeedOtherWorkspaceLeakCheckAsync(NpgsqlConnection connection)
{
const string sql = """
INSERT INTO workspaces (id, name, slug, settings_version, created_at)
VALUES (@workspaceId, 'Andere werkruimte (lekcontrole)', 'andere-werkruimte-lekcontrole', 1, now());
INSERT INTO pages (
id, workspace_id, parent_page_id, sort_key, title, slug, markdown,
document_type, document_status, current_revision_number, content_hash,
created_by, updated_by, created_at, updated_at, tags)
VALUES (
gen_random_uuid(), @workspaceId, NULL, '000000000001',
'Ontoegankelijke pagina quibblefrastication',
'ontoegankelijke-pagina', 'Bevat de zeldzame term quibblefrastication.',
0, 1, 1, md5('other-workspace'), NULL, NULL, now(), now(), ARRAY[]::text[])
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("workspaceId", Guid.NewGuid());
await command.ExecuteNonQueryAsync();
}
private static async Task PrepareReadMeasurementAsync(NpgsqlConnection connection)
{
// Run each VACUUM outside an explicit/multi-statement transaction. Both tables
// are bulk-loaded by this fixture; leaving page_revisions for autovacuum can
// create the same disk contention even though the search query reads pages.
foreach (var statement in new[]
{
"VACUUM (ANALYZE) pages;",
"VACUUM (ANALYZE) page_revisions;",
"CHECKPOINT;"
})
{
await using var command = new NpgsqlCommand(statement, connection)
{
CommandTimeout = 300
};
await command.ExecuteNonQueryAsync();
}
}
private static async Task<LatencyMeasurement> MeasureAsync(HttpClient client, StringBuilder report, string label, string path)
{
var samples = new List<double>(SampleCount);
for (var i = 0; i < SampleCount; i++)
{
var stopwatch = Stopwatch.StartNew();
using var response = await client.GetAsync(path);
stopwatch.Stop();
response.EnsureSuccessStatusCode();
samples.Add(stopwatch.Elapsed.TotalMilliseconds);
}
samples.Sort();
var p50 = Percentile(samples, 0.50);
var p95 = Percentile(samples, 0.95);
var p99 = Percentile(samples, 0.99);
report.AppendLine(CultureInfo.InvariantCulture, $" {label}: p50={p50:F1}ms p95={p95:F1}ms p99={p99:F1}ms (min={samples[0]:F1}ms max={samples[^1]:F1}ms)");
return new LatencyMeasurement(label, p95);
}
private static double Percentile(IReadOnlyList<double> sortedSamples, double percentile)
{
var index = (int)Math.Ceiling(percentile * sortedSamples.Count) - 1;
return sortedSamples[Math.Clamp(index, 0, sortedSamples.Count - 1)];
}
private static async Task<string> ExplainAsync(NpgsqlConnection connection, Guid workspaceId, string term)
{
const string sql = """
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, title
FROM pages
WHERE workspace_id = @workspaceId
AND trashed_at IS NULL
AND search_vector @@ plainto_tsquery('simple', @term)
ORDER BY ts_rank_cd(search_vector, plainto_tsquery('simple', @term)) DESC
LIMIT 20
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("term", term);
await using var reader = await command.ExecuteReaderAsync();
var lines = new StringBuilder();
while (await reader.ReadAsync())
{
lines.AppendLine(" " + reader.GetString(0));
}
return lines.ToString();
}
private static async Task<string> ExplainFuzzyAsync(NpgsqlConnection connection, Guid workspaceId, string term)
{
await using var transaction = await connection.BeginTransactionAsync();
await using (var threshold = new NpgsqlCommand("SET LOCAL pg_trgm.similarity_threshold = '0.2';", connection, transaction))
{
await threshold.ExecuteNonQueryAsync();
}
const string sql = """
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, title
FROM pages
WHERE workspace_id = @workspaceId
AND trashed_at IS NULL
AND title % @term
ORDER BY similarity(title, @term) DESC, updated_at DESC, id DESC
LIMIT 20
""";
await using var command = new NpgsqlCommand(sql, connection, transaction);
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("term", term);
var lines = new StringBuilder();
{
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
lines.AppendLine(" " + reader.GetString(0));
}
}
await transaction.CommitAsync();
return lines.ToString();
}
private static async Task<string> ExplainTagFilterAsync(
NpgsqlConnection connection,
Guid workspaceId,
string term,
string tag)
{
const string sql = """
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT p.id, p.updated_at,
ts_rank_cd(p.search_vector, plainto_tsquery('simple', @term))::double precision AS rank
FROM pages AS p
WHERE p.workspace_id = @workspaceId
AND p.trashed_at IS NULL
AND p.search_vector @@ plainto_tsquery('simple', @term)
AND p.tags @> ARRAY[@tag]::text[]
ORDER BY rank DESC, p.updated_at DESC, p.id DESC
LIMIT 20
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("term", term);
command.Parameters.AddWithValue("tag", tag);
await using var reader = await command.ExecuteReaderAsync();
var lines = new StringBuilder();
while (await reader.ReadAsync())
{
lines.AppendLine(" " + reader.GetString(0));
}
return lines.ToString();
}
private static async Task<string> IndexSizesAsync(NpgsqlConnection connection, string tableName)
{
const string sql = """
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass))
FROM pg_indexes
WHERE tablename = @tableName
ORDER BY indexname
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tableName", tableName);
await using var reader = await command.ExecuteReaderAsync();
var lines = new StringBuilder();
while (await reader.ReadAsync())
{
lines.AppendLine(CultureInfo.InvariantCulture, $" {reader.GetString(0)}: {reader.GetString(1)}");
}
return lines.ToString();
}
private sealed record LatencyMeasurement(string Label, double P95);
}
@@ -0,0 +1,99 @@
using System.Diagnostics;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>
/// M3-A07 proof at a reduced, locally-runnable scale. docs/10-search-and-indexing.md's
/// aspirational scale-verification dataset (100,000 current pages, 1,000,000 revisions)
/// requires dedicated benchmarking infrastructure and multi-minute seeding that does not
/// fit a routine local/CI test run; that full-scale verification is explicitly deferred
/// to M8's "Performance and scale evidence" gate (docs/adr/ADR-012-m3-discovery-revisions-search.md).
/// This test instead proves the search query plan and GIN indexes keep a bounded,
/// permission-filtered query fast and correct at a smaller but still non-trivial
/// synthetic scale — the same proof shape as M2-A07's 10,000-page tree scale test.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class SearchScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int PageCount = 5_000;
[Fact]
public async Task Search_stays_fast_and_correct_against_5000_pages_with_one_needle()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
Guid needleId;
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
var owner = await db.Users.AsNoTracking().FirstAsync();
var sortKey = SortKey.First();
var batch = new List<Page>(1000);
Page? needle = null;
for (var i = 0; i < PageCount; i++)
{
var isNeedle = i == PageCount / 2;
var page = Page.Create(
workspace.Id,
null,
sortKey,
PageTitle.Create(isNeedle ? "Uniek vindbaar trefwoord xyzzyplugh" : $"Vulpagina {i:D5}"),
DocumentType.WikiPage,
isNeedle ? "Deze pagina bevat het unieke trefwoord xyzzyplugh nergens anders." : "Gewone opvultekst zonder bijzondere termen.",
owner.Id,
DateTimeOffset.UtcNow);
if (isNeedle)
{
needle = page;
}
batch.Add(page);
sortKey = SortKey.Next(sortKey);
if (batch.Count == 1000)
{
db.Pages.AddRange(batch);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
batch.Clear();
}
}
if (batch.Count > 0)
{
db.Pages.AddRange(batch);
await db.SaveChangesAsync();
}
Assert.NotNull(needle);
needleId = needle.Id;
}
var stopwatch = Stopwatch.StartNew();
var response = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=xyzzyplugh");
stopwatch.Stop();
Assert.NotNull(response);
Assert.Contains(response.Items, item => item.Id == needleId);
// Generous bound for a CI/dev machine, proving the GIN-indexed query stays
// bounded rather than scanning ~5,000 rows' worth of text at request time — not
// a precise benchmark (that belongs to the M8 release scale suite at full
// documented scale).
Assert.True(
stopwatch.Elapsed < TimeSpan.FromSeconds(3),
$"Searching {PageCount} pages for one needle term took {stopwatch.Elapsed}, expected well under 3s.");
}
}
@@ -0,0 +1,147 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>PostgreSQL full-text + trigram search: title/body matching, tag/type
/// filters, typo tolerance, trash exclusion and authentication
/// (docs/27-acceptance-matrix.md, M3-A04..A06).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class SearchTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Search_finds_a_page_by_title_and_by_body_text()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "PostgreSQL back-uprunbook", "Runbook", "Stappen om een back-up te herstellen.", null));
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Onderwerp zonder verband", "WikiPage", "Volledig andere inhoud.", null));
var byTitle = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=back-uprunbook");
Assert.NotNull(byTitle);
Assert.Contains(byTitle.Items, item => item.Title == "PostgreSQL back-uprunbook");
var byBody = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=herstellen");
Assert.NotNull(byBody);
Assert.Contains(byBody.Items, item => item.Title == "PostgreSQL back-uprunbook");
Assert.DoesNotContain(byBody.Items, item => item.Title == "Onderwerp zonder verband");
}
[Fact]
public async Task Search_is_typo_tolerant_for_titles_via_trigram_similarity()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Troubleshooting netwerkverbinding", "Troubleshooting", "Inhoud.", null));
// Deliberate typo: "Trubleshootng" instead of "Troubleshooting".
var response = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=Trubleshootng");
Assert.NotNull(response);
Assert.Contains(response.Items, item => item.Title == "Troubleshooting netwerkverbinding");
}
[Fact]
public async Task Search_can_be_filtered_by_tag_and_by_document_type()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, "Gearchiveerd project", "ProjectDocument", "Inhoud.", null, ["archief", "netwerk"]));
await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, "Actief project", "ProjectDocument", "Inhoud.", null, ["actief"]));
var byTag = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=project&tag=archief");
Assert.NotNull(byTag);
Assert.Single(byTag.Items);
Assert.Equal("Gearchiveerd project", byTag.Items[0].Title);
var byType = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=project&type=ProjectDocument");
Assert.NotNull(byType);
Assert.Equal(2, byType.Items.Count);
}
[Fact]
public async Task Search_never_returns_a_trashed_page()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Binnenkort verwijderde pagina", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var beforeTrash = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=verwijderde");
Assert.NotNull(beforeTrash);
Assert.Contains(beforeTrash.Items, item => item.Id == page.Id);
await SetupHelper.PostWithCsrfAndIfMatchAsync(client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag);
var afterTrash = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=verwijderde");
Assert.NotNull(afterTrash);
Assert.DoesNotContain(afterTrash.Items, item => item.Id == page.Id);
}
[Fact]
public async Task Search_requires_authentication()
{
using var client = Factory.CreateClient();
var response = await client.GetAsync("/api/v1/search?q=iets");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Search_cursor_pagination_is_deterministic_and_covers_every_result_without_duplicates()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
for (var i = 0; i < 12; i++)
{
await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, $"Cursortest onderwerp {i:D2}", "WikiPage", "Gedeelde zoekterm cursorproef.", null));
}
var seen = new HashSet<Guid>();
string? cursor = null;
var pages = 0;
do
{
var url = cursor is null
? "/api/v1/search?q=cursorproef&limit=5"
: $"/api/v1/search?q=cursorproef&limit=5&cursor={Uri.EscapeDataString(cursor)}";
var response = await client.GetFromJsonAsync<SearchResponse>(url);
Assert.NotNull(response);
foreach (var item in response.Items)
{
Assert.True(seen.Add(item.Id), $"Item {item.Id} was returned on more than one page.");
}
cursor = response.NextCursor;
pages++;
Assert.True(pages < 10, "Pagination did not terminate within a reasonable number of pages.");
} while (cursor is not null);
Assert.Equal(12, seen.Count);
}
}
@@ -0,0 +1,326 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>
/// Automatic reclamation of expired, abandoned page-creation staging sessions
/// (docs/adr/ADR-029-v2-r5-final-hardening.md). Every scenario here drives the exact
/// same <c>CleanupExpiredPageCreationSessionsUseCase</c> the background worker itself
/// calls, through the admin "run now" endpoint — real PostgreSQL, real attachment
/// blob files on disk, no mocked persistence.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class StagingSessionCleanupTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private static readonly byte[] OtherPngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0xFF, 0xEE, 0xDD, 0xCC];
private async Task<HttpClient> OwnerClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
private static async Task<Guid> BeginSessionAsync(HttpClient client)
{
var response = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/pages/staged", new BeginPageCreationSessionRequest(null));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var session = await response.Content.ReadFromJsonAsync<PageCreationSessionResponse>();
Assert.NotNull(session);
return session.SessionId;
}
private static async Task<Guid> UploadAsync(HttpClient client, Guid sessionId, byte[] bytes, string filename)
{
var upload = await SetupHelper.PostMultipartWithCsrfAsync(
client, $"/api/v1/pages/staged/{sessionId}/attachments", bytes, filename, "image/png");
Assert.Equal(HttpStatusCode.OK, upload.StatusCode);
var attachment = await upload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(attachment);
return attachment.Id;
}
private async Task ExpireAsync(Guid sessionId)
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
await db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE page_creation_sessions SET expires_at = {DateTimeOffset.UtcNow.AddHours(-1)} WHERE id = {sessionId}");
}
private static async Task<RunStagingCleanupResponse> RunCleanupAsync(HttpClient client, bool dryRun = false)
{
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/staging-cleanup/run", new RunStagingCleanupRequest(dryRun));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<RunStagingCleanupResponse>();
Assert.NotNull(body);
return body;
}
[Fact]
public async Task An_empty_expired_session_is_reclaimed_with_nothing_to_remove()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
await ExpireAsync(sessionId);
var result = await RunCleanupAsync(owner);
Assert.Equal(1, result.Result.SessionsClaimed);
Assert.Equal(0, result.Result.AttachmentReferencesRemoved);
Assert.Equal(0, result.Result.BlobsRemoved);
Assert.Empty(result.Result.Warnings);
}
[Fact]
public async Task An_expired_session_with_one_attachment_removes_the_reference_and_the_blob()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadAsync(owner, sessionId, PngBytes, "een.png");
await ExpireAsync(sessionId);
var result = await RunCleanupAsync(owner);
Assert.Equal(1, result.Result.SessionsClaimed);
Assert.Equal(1, result.Result.AttachmentReferencesRemoved);
Assert.Equal(1, result.Result.BlobsRemoved);
var content = await owner.GetAsync($"/api/v1/attachments/{attachmentId}/content");
Assert.Equal(HttpStatusCode.NotFound, content.StatusCode);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
Assert.Equal(0, await db.AttachmentBlobs.CountAsync(blob => blob.ByteLength == PngBytes.LongLength));
}
[Fact]
public async Task An_expired_session_with_multiple_attachments_removes_every_one()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
var first = await UploadAsync(owner, sessionId, PngBytes, "een.png");
var second = await UploadAsync(owner, sessionId, OtherPngBytes, "twee.png");
await ExpireAsync(sessionId);
var result = await RunCleanupAsync(owner);
Assert.Equal(2, result.Result.AttachmentReferencesRemoved);
Assert.Equal(2, result.Result.BlobsRemoved);
Assert.Equal(HttpStatusCode.NotFound, (await owner.GetAsync($"/api/v1/attachments/{first}/content")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await owner.GetAsync($"/api/v1/attachments/{second}/content")).StatusCode);
}
[Fact]
public async Task A_blob_still_shared_by_a_published_page_survives_cleanup_of_an_expired_session()
{
using var owner = await OwnerClientAsync();
var pageResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/pages", new CreatePageRequest(null, $"Blob-behoud {Guid.NewGuid():N}", "WikiPage", "Inhoud.", null));
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
var publishedUpload = await SetupHelper.PostMultipartWithCsrfAsync(
owner, $"/api/v1/pages/{page.Id}/attachments", PngBytes, "gepubliceerd.png", "image/png");
Assert.Equal(HttpStatusCode.OK, publishedUpload.StatusCode);
var publishedAttachment = await publishedUpload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(publishedAttachment);
var sessionId = await BeginSessionAsync(owner);
var stagedId = await UploadAsync(owner, sessionId, PngBytes, "nog-niet.png");
await ExpireAsync(sessionId);
var result = await RunCleanupAsync(owner);
Assert.Equal(1, result.Result.AttachmentReferencesRemoved);
// The blob itself is still referenced by the published page's own reference —
// never physically deleted.
Assert.Equal(0, result.Result.BlobsRemoved);
Assert.Equal(HttpStatusCode.NotFound, (await owner.GetAsync($"/api/v1/attachments/{stagedId}/content")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await owner.GetAsync($"/api/v1/attachments/{publishedAttachment.Id}/content")).StatusCode);
}
[Fact]
public async Task An_active_not_yet_expired_session_is_never_touched()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadAsync(owner, sessionId, PngBytes, "actief.png");
var result = await RunCleanupAsync(owner);
Assert.Equal(0, result.Result.SessionsExpired);
Assert.Equal(0, result.Result.SessionsClaimed);
// Still fully usable afterwards — the session was never touched.
Assert.Equal(HttpStatusCode.OK, (await owner.GetAsync($"/api/v1/attachments/{attachmentId}/content")).StatusCode);
var commit = await SetupHelper.PostWithCsrfAsync(
owner, $"/api/v1/pages/staged/{sessionId}/commit",
new CommitPageCreationSessionRequest("Nog steeds actief", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.OK, commit.StatusCode);
}
[Fact]
public async Task Running_cleanup_twice_in_a_row_is_safe_and_the_second_run_finds_nothing_left_to_do()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
await UploadAsync(owner, sessionId, PngBytes, "dubbel.png");
await ExpireAsync(sessionId);
var first = await RunCleanupAsync(owner);
Assert.Equal(1, first.Result.SessionsClaimed);
var second = await RunCleanupAsync(owner);
Assert.Equal(0, second.Result.SessionsExpired);
Assert.Equal(0, second.Result.SessionsClaimed);
Assert.Empty(second.Result.Warnings);
}
[Fact]
public async Task Two_concurrent_cleanup_runs_never_double_process_the_same_session()
{
using var owner = await OwnerClientAsync();
var sessionIds = new List<Guid>();
for (var i = 0; i < 5; i++)
{
var sessionId = await BeginSessionAsync(owner);
await UploadAsync(owner, sessionId, PngBytes, $"gelijktijdig-{i}.png");
await ExpireAsync(sessionId);
sessionIds.Add(sessionId);
}
using var ownerForSecondCall = await OwnerClientAsync();
var results = await Task.WhenAll(RunCleanupAsync(owner), RunCleanupAsync(ownerForSecondCall));
var totalClaimed = results.Sum(result => result.Result.SessionsClaimed);
// Exactly 5 sessions total, each claimed by exactly one of the two concurrent
// runs — never zero (lost forever), never two (double-processed).
Assert.Equal(5, totalClaimed);
Assert.All(results, result => Assert.Empty(result.Result.Warnings));
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var stillOpen = await db.PageCreationSessions
.CountAsync(session => sessionIds.Contains(session.Id) && session.Status == PageCreationSessionStatus.Open);
Assert.Equal(0, stillOpen);
}
[Fact]
public async Task A_missing_physical_blob_file_does_not_fail_cleanup_and_the_database_row_is_still_removed()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadAsync(owner, sessionId, PngBytes, "verdwenen-bestand.png");
await ExpireAsync(sessionId);
// Simulate the file having already been removed out-of-band (e.g. a previous
// partial run's crash-after-DB-delete, or manual operator intervention).
string blobPath;
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var reference = await db.AttachmentReferences.SingleAsync(reference => reference.Id == attachmentId);
var hash = reference.BlobSha256;
blobPath = Path.Combine(Factory.AttachmentsDirectory, hash[..2], hash[2..4], hash);
}
Assert.True(File.Exists(blobPath));
File.Delete(blobPath);
var result = await RunCleanupAsync(owner);
Assert.Empty(result.Result.Warnings);
Assert.Equal(1, result.Result.AttachmentReferencesRemoved);
// The blob store reports "already gone" rather than throwing — the database
// row is still cleaned up (reported as removed) instead of dangling forever.
Assert.Equal(1, result.Result.BlobsRemoved);
}
[Fact]
public async Task Dry_run_reports_the_expired_count_without_claiming_or_deleting_anything()
{
using var owner = await OwnerClientAsync();
var sessionId = await BeginSessionAsync(owner);
var attachmentId = await UploadAsync(owner, sessionId, PngBytes, "droog.png");
await ExpireAsync(sessionId);
var preview = await RunCleanupAsync(owner, dryRun: true);
Assert.True(preview.DryRun);
Assert.Equal(1, preview.Result.SessionsExpired);
Assert.Equal(0, preview.Result.SessionsClaimed);
// Nothing was actually claimed or deleted by the dry run — a real run
// afterwards still finds the same session expired and claims (and cleans up)
// it exactly once. The attachment itself already reads as gone even before the
// real cleanup pass runs (an expired-but-not-yet-reclaimed session is already
// treated as closed for authorization purposes, matching
// CommitPageCreationSessionUseCase's own expiry check) — the dry run does not
// change that outcome, it only reports counts without mutating anything.
Assert.Equal(HttpStatusCode.NotFound, (await owner.GetAsync($"/api/v1/attachments/{attachmentId}/content")).StatusCode);
var real = await RunCleanupAsync(owner);
Assert.Equal(1, real.Result.SessionsClaimed);
Assert.Equal(1, real.Result.AttachmentReferencesRemoved);
}
[Fact]
public async Task Status_reflects_active_and_expired_counts_and_the_most_recent_run()
{
using var owner = await OwnerClientAsync();
var activeSession = await BeginSessionAsync(owner);
var expiredSession = await BeginSessionAsync(owner);
await ExpireAsync(expiredSession);
var beforeStatus = await owner.GetFromJsonAsync<StagingCleanupStatusResponse>("/api/v1/admin/staging-cleanup/status");
Assert.NotNull(beforeStatus);
Assert.Equal(2, beforeStatus.ActiveSessionCount);
Assert.Equal(1, beforeStatus.ExpiredPendingCleanupCount);
await RunCleanupAsync(owner);
var afterStatus = await owner.GetFromJsonAsync<StagingCleanupStatusResponse>("/api/v1/admin/staging-cleanup/status");
Assert.NotNull(afterStatus);
Assert.Equal(1, afterStatus.ActiveSessionCount);
Assert.Equal(0, afterStatus.ExpiredPendingCleanupCount);
Assert.NotNull(afterStatus.LastResult);
Assert.Equal("manual", afterStatus.LastRunTrigger);
Assert.NotNull(afterStatus.LastRunCompletedAt);
// The still-active session remains untouched.
var commit = await SetupHelper.PostWithCsrfAsync(
owner, $"/api/v1/pages/staged/{activeSession}/commit",
new CommitPageCreationSessionRequest("Nog steeds actief", "WikiPage", "# Inhoud", null));
Assert.Equal(HttpStatusCode.OK, commit.StatusCode);
}
[Fact]
public async Task A_reader_cannot_view_status_or_trigger_cleanup()
{
using var owner = await OwnerClientAsync();
var createResponse = await SetupHelper.PostWithCsrfAsync(
owner, "/api/v1/admin/users",
new CreateUserRequest("Lezer", "lezer-cleanup@itworx.test", "reader-strong-password", "Reader"));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
using var reader = Factory.CreateClient();
await SetupHelper.LoginAsync(reader, "lezer-cleanup@itworx.test", "reader-strong-password");
Assert.Equal(HttpStatusCode.Forbidden, (await reader.GetAsync("/api/v1/admin/staging-cleanup/status")).StatusCode);
var run = await SetupHelper.PostWithCsrfAsync(reader, "/api/v1/admin/staging-cleanup/run", new RunStagingCleanupRequest(true));
Assert.Equal(HttpStatusCode.Forbidden, run.StatusCode);
}
}
@@ -0,0 +1,65 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>Templates create valid study note, runbook, ADR and troubleshooting
/// documents (docs/27-acceptance-matrix.md, M2-A06).</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class TemplateTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task All_four_built_in_templates_are_listed()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var templates = await client.GetFromJsonAsync<IReadOnlyList<TemplateSummary>>("/api/v1/templates");
Assert.NotNull(templates);
Assert.Equal(4, templates.Count);
Assert.Contains(templates, t => t.Key == "study-note");
Assert.Contains(templates, t => t.Key == "runbook");
Assert.Contains(templates, t => t.Key == "architecture-decision");
Assert.Contains(templates, t => t.Key == "troubleshooting");
}
[Theory]
[InlineData("study-note", "StudyNote", "## Kernidee")]
[InlineData("runbook", "Runbook", "## Rollback")]
[InlineData("architecture-decision", "ArchitectureDecision", "## Beslissing")]
[InlineData("troubleshooting", "Troubleshooting", "## Escalatie")]
public async Task Creating_a_page_from_a_template_produces_the_expected_structure(
string templateKey,
string expectedDocumentType,
string expectedSectionHeading)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, $"Sjabloontest {templateKey}", null, null, templateKey));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Equal(expectedDocumentType, page.DocumentType);
Assert.Contains($"# Sjabloontest {templateKey}", page.Markdown, StringComparison.Ordinal);
Assert.Contains(expectedSectionHeading, page.Markdown, StringComparison.Ordinal);
}
[Fact]
public async Task An_unknown_template_key_is_rejected()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Ongeldig sjabloon", null, null, "does-not-exist"));
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
}
@@ -0,0 +1,95 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>The explicit administrative purge never removes a page still within its
/// trash retention window, and is never reachable except by an administrator
/// (docs/09-data-safety.md: "never a permanent delete through a normal UI flow").</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class TrashPurgeTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Purge_does_not_remove_a_trashed_page_still_within_its_retention_window()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Recent verwijderd", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
await SetupHelper.PostWithCsrfAndIfMatchAsync(client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag);
var purgeResponse = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/admin/trash/purge", (object?)null);
Assert.Equal(HttpStatusCode.OK, purgeResponse.StatusCode);
var result = await purgeResponse.Content.ReadFromJsonAsync<TrashPurgeResponse>();
Assert.NotNull(result);
Assert.Equal(0, result.PurgedCount);
var stillThere = await client.GetAsync($"/api/v1/pages/{page.Id}");
Assert.Equal(HttpStatusCode.OK, stillThere.StatusCode);
}
[Fact]
public async Task Purge_permanently_removes_a_trashed_page_once_its_retention_window_has_elapsed()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Lang geleden verwijderd", "WikiPage", "Inhoud.", null));
var page = await createResponse.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
await SetupHelper.PostWithCsrfAndIfMatchAsync(client, $"/api/v1/pages/{page.Id}/trash", (object?)null, page.ETag);
// Directly back-date the retention window rather than waiting 30 real days —
// the use case's own eligibility computation (Trash(...) with a fixed
// TimeSpan) is exercised elsewhere; this test proves the purge query itself
// correctly identifies and removes an eligible row.
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
await db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE pages SET purge_after = {DateTimeOffset.UtcNow.AddDays(-1)} WHERE id = {page.Id}");
}
var purgeResponse = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/admin/trash/purge", (object?)null);
Assert.Equal(HttpStatusCode.OK, purgeResponse.StatusCode);
var result = await purgeResponse.Content.ReadFromJsonAsync<TrashPurgeResponse>();
Assert.NotNull(result);
Assert.Equal(1, result.PurgedCount);
var afterPurge = await client.GetAsync($"/api/v1/pages/{page.Id}");
Assert.Equal(HttpStatusCode.NotFound, afterPurge.StatusCode);
}
[Fact]
public async Task Purge_is_administrator_only()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var editorEmail = "redacteur-purge@itworx.test";
var editorPassword = "correct-horse-battery-staple";
await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/users",
new CreateUserRequest("Redacteur", editorEmail, editorPassword, "Editor"));
using var editorClient = Factory.CreateClient();
await SetupHelper.LoginAsync(editorClient, editorEmail, editorPassword);
var response = await SetupHelper.PostWithCsrfAsync(editorClient, "/api/v1/admin/trash/purge", (object?)null);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}