Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
+103
@@ -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;
|
||||
}
|
||||
}
|
||||
+195
@@ -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><tag></c> into inert escaped text
|
||||
/// (<c>&lt;tag&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 ["", "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);
|
||||
}
|
||||
}
|
||||
+243
@@ -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".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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+304
@@ -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);
|
||||
}
|
||||
}
|
||||
+326
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user