Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
commit ad8ad252a9
825 files changed
+124216

No files matched your search

@@ -0,0 +1,212 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Migration;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Migration;
/// <summary>
/// M9-A01: "A complete synthetic cutover rehearsal, including rollback, succeeds
/// before real data is used." Distinct from `LeafWikiImportTests` (which proves each
/// mechanism — preflight, execution, idempotency, rollback, conflict handling —
/// correct in isolation): this test walks the actual documented
/// "Real migration procedure" (docs/17-leafwiki-migration.md) end to end, against one
/// coherent synthetic fixture combining nested pages, UTF-8/special characters,
/// resolved and unresolved wiki-links, a present and a missing attachment reference,
/// and produces a written cutover-rehearsal report (the "cutover record" M9's
/// `requiredEvidence` names) exactly as an operator would read before a real cutover.
/// Steps 1-3 and 9-15 of the documented procedure are LeafWiki-source-specific or
/// require the real production Unraid server (M9-A02..A06, all marked
/// <c>"external": true</c> in product/milestones.json) and are explicitly out of
/// scope here — this proves everything locally executable.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class CutoverRehearsalTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] DiagramPngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
[Fact]
public async Task A_full_synthetic_cutover_rehearsal_including_rollback_succeeds()
{
var report = new StringBuilder();
report.AppendLine(CultureInfo.InvariantCulture, $"M9 synthetic cutover rehearsal report — {DateTimeOffset.UtcNow:O}");
report.AppendLine("Synthetic fixture only — no real LeafWiki source was read (CLAUDE.md §2/§9).");
report.AppendLine();
var source = Path.Combine(Factory.ImportsDirectory, "cutover-rehearsal");
Directory.CreateDirectory(source);
BuildFixture(source);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var sourceRelativePath = Path.GetRelativePath(Factory.ImportsDirectory, source).Replace('\\', '/');
// --- Step: inspector/preflight (docs/17 "Real migration procedure" step 4) ---
var preflightResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight",
new PreflightImportRequest(sourceRelativePath, $"cutover-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.OK, preflightResponse.StatusCode);
var plan = await preflightResponse.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.NotNull(plan.JobId);
report.AppendLine("Preflight:");
report.AppendLine(CultureInfo.InvariantCulture, $" Adapter: {plan.AdapterName}");
report.AppendLine(CultureInfo.InvariantCulture, $" Discovered: {plan.DiscoveredCount}, Planned: {plan.PlannedCount}");
report.AppendLine(CultureInfo.InvariantCulture, $" Warnings: {plan.Warnings.Count}");
foreach (var warning in plan.Warnings)
{
report.AppendLine(CultureInfo.InvariantCulture, $" - {warning}");
}
report.AppendLine(CultureInfo.InvariantCulture, $" Blockers: {plan.Blockers.Count}");
// --- Step: resolve all blockers before proceeding (docs/17 step 5) ---
// A well-formed synthetic fixture produces none; a real cutover would stop
// here and resolve each one before continuing.
Assert.Empty(plan.Blockers);
// One reported warning is expected: the missing attachment reference below.
Assert.Contains(plan.Warnings, warning => warning.Contains("ontbrekend.png", StringComparison.Ordinal));
// --- Step: import into a non-production target (docs/17 step 6) ---
// "Non-production target" here is this test class's own isolated database —
// never the real production instance, matching every other integration test
// in this suite.
var executeResponse = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(sourceRelativePath)}",
(object?)null);
Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode);
var executeResult = await executeResponse.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(executeResult);
report.AppendLine();
report.AppendLine("Execution:");
report.AppendLine(CultureInfo.InvariantCulture, $" Imported: {executeResult.Imported}, Skipped: {executeResult.Skipped}, Failed: {executeResult.Failed}");
report.AppendLine(CultureInfo.InvariantCulture, $" Source unchanged after execution (fingerprint before == after): {executeResult.SourceUnchangedAfterExecution}");
Assert.Equal(0, executeResult.Failed);
// Source fingerprint immutability (docs/17 "A complete source fingerprint is
// captured before and after") — the synthetic source on disk was never
// mutated by the importer.
Assert.True(executeResult.SourceUnchangedAfterExecution);
// --- Step: validate representative content (docs/17 step 7 / "Validation") ---
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
// The filesystem-markdown adapter treats a root index file as one root-level
// "front page", not an umbrella parent of every other top-level directory
// (matching LeafWikiImportTests' own established preflight assertions) — so
// "Overstap-hoofdpagina" and "School" are root-level siblings here.
Assert.Contains(roots.Items, item => item.Title == "Overstap-hoofdpagina");
var schoolPage = Assert.Single(roots.Items, item => item.Title == "School");
var mathChildren = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{schoolPage.Id}/children");
Assert.NotNull(mathChildren);
var mathPage = Assert.Single(mathChildren.Items, item => item.Title == "Wiskunde: café-oefeningen");
var mathDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{mathPage.Id}");
Assert.NotNull(mathDetail);
// Link resolution: the wiki-links are authored on the School page (which
// links to Wiskunde and to a nonexistent page), so the resolved-vs-unresolved
// check reads the School page's own rendered HTML, not the link target's.
var schoolDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{schoolPage.Id}");
Assert.NotNull(schoolDetail);
var linksResolved = schoolDetail.RenderedHtml.Contains("<a href=", StringComparison.Ordinal);
var linksUnresolvedPreserved = schoolDetail.RenderedHtml.Contains("[[Onbestaande pagina]]", StringComparison.Ordinal);
Assert.True(linksResolved);
Assert.True(linksUnresolvedPreserved);
// UTF-8/special characters preserved exactly, not mangled or replaced.
Assert.Contains("café-oefeningen", mathDetail.Title, StringComparison.Ordinal);
Assert.Contains("Formules met bijzondere tekens: café, naïef, © 2026.", mathDetail.Markdown, StringComparison.Ordinal);
// Attachment present and searchable/servable; missing one already reported as
// a preflight warning above, never silently dropped.
var attachments = await client.GetFromJsonAsync<ItWorx.KnowledgeHub.Contracts.Attachments.AttachmentListResponse>($"/api/v1/pages/{mathPage.Id}/attachments");
Assert.NotNull(attachments);
var attachment = Assert.Single(attachments.Items);
var attachmentContentResponse = await client.GetAsync($"/api/v1/attachments/{attachment.Id}/content");
Assert.Equal(HttpStatusCode.OK, attachmentContentResponse.StatusCode);
var attachmentBytes = await attachmentContentResponse.Content.ReadAsByteArrayAsync();
Assert.Equal(DiagramPngBytes, attachmentBytes);
// Target searchability: the imported content is findable, not just present.
var searchResponse = await client.GetFromJsonAsync<SearchResponse>("/api/v1/search?q=bijzondere");
Assert.NotNull(searchResponse);
Assert.Contains(searchResponse.Items, item => item.Id == mathPage.Id);
report.AppendLine();
report.AppendLine("Validation (all checked, all passed):");
report.AppendLine(" Hierarchy (Overstap-hoofdpagina and School as root siblings, Wiskunde nested under School): OK");
report.AppendLine(CultureInfo.InvariantCulture, $" Resolved link rendered as anchor: {linksResolved}");
report.AppendLine(CultureInfo.InvariantCulture, $" Unresolved link preserved literally, not broken: {linksUnresolvedPreserved}");
report.AppendLine(" UTF-8/special characters preserved exactly: True");
report.AppendLine(CultureInfo.InvariantCulture, $" Attachment present and byte-identical to source: True ({attachment.Filename}, {attachmentBytes.Length} bytes)");
report.AppendLine(" Target searchability (imported content findable): True");
// --- Step: rehearse rollback (docs/17 step 8) ---
var rollbackResponse = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/admin/imports/{plan.JobId}/rollback", (object?)null);
Assert.Equal(HttpStatusCode.OK, rollbackResponse.StatusCode);
var rollback = await rollbackResponse.Content.ReadFromJsonAsync<RollbackImportResponse>();
Assert.NotNull(rollback);
var rootsAfterRollback = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(rootsAfterRollback);
Assert.DoesNotContain(rootsAfterRollback.Items, item => item.Title == "Overstap-hoofdpagina");
report.AppendLine();
report.AppendLine("Rollback rehearsal:");
report.AppendLine(CultureInfo.InvariantCulture, $" Removed pages: {rollback.RemovedPages}, Preserved pages: {rollback.PreservedPages}, Removed attachments: {rollback.RemovedAttachments}");
report.AppendLine(" Root import page no longer present after rollback: True");
report.AppendLine(" Source file on disk untouched throughout (never renamed/repaired/deleted): True");
report.AppendLine();
report.AppendLine("Steps requiring a real LeafWiki source and/or production Unraid server");
report.AppendLine("(M9-A02..A06, external per product/milestones.json — not exercised here):");
report.AppendLine(" - Real source fingerprint/inspection (M9-A02).");
report.AppendLine(" - Real import report covering every discovered item (M9-A03).");
report.AppendLine(" - User validation of representative real content (M9-A04).");
report.AppendLine(" - LeafWiki read-only rollback window (M9-A05).");
report.AppendLine(" - Production deployment/backup/restore verification on the target Unraid server (M9-A06).");
var reportPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "artifacts", "m9-cutover-rehearsal-report.txt");
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
await File.WriteAllTextAsync(reportPath, report.ToString());
}
private static void BuildFixture(string root)
{
WriteFile(root, "index.md", "# Overstap-hoofdpagina\nDe hoofdpagina van de synthetische overstap-rehearsal.");
WriteFile(
root,
"school/index.md",
"# School\nZie [[Wiskunde: café-oefeningen]] en [[Onbestaande pagina]] voor meer.");
WriteFile(
root,
"school/wiskunde.md",
"# Wiskunde: café-oefeningen\n" +
"Formules met bijzondere tekens: café, naïef, © 2026.\n\n" +
// Asset references are matched against the fingerprint's source-root-
// relative path, not a path relative to the referencing Markdown file's
// own directory — so "school/diagram.png" here, not "diagram.png".
"![Diagram](school/diagram.png)\n\n" +
"![Ontbrekend](school/ontbrekend.png)\n");
File.WriteAllBytes(Path.Combine(root, "school", "diagram.png"), DiagramPngBytes);
// ontbrekend.png is deliberately never written — proves the importer reports
// a missing referenced attachment rather than silently dropping the
// reference or failing the whole import.
}
private static void WriteFile(string root, string relativePath, string content)
{
var full = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
}
@@ -0,0 +1,192 @@
using ItWorx.KnowledgeHub.Domain.Identity;
using ItWorx.KnowledgeHub.Domain.Knowledge;
using ItWorx.KnowledgeHub.Domain.Projects;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.Migration;
/// <summary>
/// V2-M9: rehearses the one real production upgrade path — an existing, already-
/// populated V1 database (every migration through <c>AddAutomationAndOutbox</c>,
/// matching what real production already had before any V2 code ever ran) being
/// upgraded in place by the single new V2 migration
/// (<c>AddStructuredInfrastructureKnowledge</c>). A real production LeafWiki/database
/// snapshot is not available in this environment (an external blocker, not a reason to
/// skip local verification — CLAUDE.md §3) — this uses a synthetic V1-shaped database
/// instead, built the same way <c>RestoreServiceTests.cs</c>'s clean-target pattern
/// already does (a fresh, separately-created database on the shared Testcontainers
/// Postgres server).
///
/// This closes a genuinely different gap than the existing backup/restore tests: every
/// prior migration test starts from an *empty* database and migrates straight to
/// latest, or restores a backup into a freshly-migrated-to-latest target. Neither
/// exercises "a database that already has real content, migrated forward one more
/// step" — the actual shape of a real production rollout.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class InPlaceUpgradeRehearsalTests(PostgresContainerFixture postgres)
{
// The last migration production already has, before any V2 code ever ran —
// confirmed by direct inspection of every migration file: this is the migration
// immediately preceding AddStructuredInfrastructureKnowledge.
private const string LastV1Migration = "20260801023652_AddAutomationAndOutbox";
[Fact]
public async Task Existing_V1_content_survives_the_V2_infrastructure_migration_completely_unchanged()
{
var databaseName = $"upgrade_rehearsal_{Guid.NewGuid():N}";
await using (var admin = new NpgsqlConnection(postgres.AdminConnectionString))
{
await admin.OpenAsync();
await using var create = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin);
await create.ExecuteNonQueryAsync();
}
var connectionString = new NpgsqlConnectionStringBuilder(postgres.AdminConnectionString)
{
Database = databaseName
}.ConnectionString;
try
{
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
.UseSnakeCaseNamingConvention()
.Options;
Guid workspaceId, pageId, projectId;
string pageContentHashBeforeUpgrade, projectSlugBeforeUpgrade;
DateTimeOffset pageUpdatedAtBeforeUpgrade;
// Step 1: migrate only to the last migration real production already has,
// never touching the new V2 tables — matching production's actual current
// state exactly, not a hypothetical.
await using (var db = new KnowledgeHubDbContext(options))
{
await db.Database.GetService<IMigrator>().MigrateAsync(LastV1Migration);
var now = DateTimeOffset.UtcNow;
var workspace = Workspace.Create(WorkspaceName.Create("Productiewerkruimte"), now);
db.Workspaces.Add(workspace);
workspaceId = workspace.Id;
var actorId = Guid.NewGuid();
var page = Page.Create(
workspace.Id,
parentPageId: null,
SortKey.First(),
PageTitle.Create("Bestaande productiepagina"),
DocumentType.WikiPage,
"# Bestaande productiepagina\n\nDeze inhoud bestond al vóór de V2-migratie.",
actorId,
now);
db.Pages.Add(page);
pageId = page.Id;
var project = Project.Create(
workspace.Id,
"Bestaand productieproject",
"Een project dat al bestond vóór de V2-migratie.",
ProjectPriority.Medium,
"Actieve productiewerklast.",
ownerId: actorId,
createdBy: actorId,
now);
projectId = project.Id;
await db.SaveChangesAsync();
// Seed the V1-shaped row with the V1 schema. The current EF model
// already maps columns introduced after V1 (such as the project
// icon), so using db.Projects.Add here would ask PostgreSQL for a
// column that correctly does not exist until the upgrade below.
await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO projects
(id, workspace_id, name, slug, summary, status, priority,
current_state_markdown, owner_id, technologies, created_by,
created_at, updated_at)
VALUES
({project.Id}, {workspace.Id}, {project.Name}, {project.Slug},
{project.Summary}, {(short)project.Status}, {(short)project.Priority},
{project.CurrentStateMarkdown}, {project.OwnerId}, {project.TechnologiesValue},
{project.CreatedBy}, {project.CreatedAt}, {project.UpdatedAt})
""");
pageContentHashBeforeUpgrade = page.ContentHash;
// PostgreSQL's timestamptz column only stores microsecond precision —
// truncate the in-memory .NET tick-precision value the same way before
// comparing, so this asserts real round-trip fidelity rather than a
// false failure from a column-precision artifact unrelated to the
// migration itself.
pageUpdatedAtBeforeUpgrade = TruncateToMicroseconds(page.UpdatedAt);
projectSlugBeforeUpgrade = project.Slug;
}
// Step 2: the real production upgrade action — apply every pending
// migration (here, exactly one: AddStructuredInfrastructureKnowledge) to
// the now-populated database, exactly as SchemaGuard/Program.cs's normal
// startup migration would against a real upgraded production instance.
await using (var db = new KnowledgeHubDbContext(options))
{
await db.Database.MigrateAsync();
}
// Step 3: a fresh context/connection (never reusing the one that ran the
// migration) re-reads the pre-existing content and the new tables.
await using (var db = new KnowledgeHubDbContext(options))
{
var workspace = await db.Workspaces.FindAsync(workspaceId);
Assert.NotNull(workspace);
Assert.Equal("Productiewerkruimte", workspace!.Name);
var page = await db.Pages.FindAsync(pageId);
Assert.NotNull(page);
Assert.Equal("Bestaande productiepagina", page!.TitleValue);
Assert.Equal(
"# Bestaande productiepagina\n\nDeze inhoud bestond al vóór de V2-migratie.",
page.Markdown);
Assert.Equal(pageContentHashBeforeUpgrade, page.ContentHash);
Assert.Equal(pageUpdatedAtBeforeUpgrade, page.UpdatedAt);
Assert.False(page.IsTrashed);
var project = await db.Projects.FindAsync(projectId);
Assert.NotNull(project);
Assert.Equal("Bestaand productieproject", project!.Name);
Assert.Equal(projectSlugBeforeUpgrade, project.Slug);
Assert.Equal(ProjectLifecycleStatus.Concept, project.Status);
// The new V2 tables exist and are queryable (empty — no inventory
// snapshot has ever been posted against this rehearsal database),
// proving the upgrade is additive, not merely non-destructive to what
// already existed.
Assert.Equal(0, await db.InventorySnapshots.CountAsync());
Assert.Equal(0, await db.InfrastructureServices.CountAsync());
Assert.Equal(0, await db.EditorialProfiles.CountAsync());
Assert.Equal(0, await db.ManualOverrides.CountAsync());
}
}
finally
{
await using var admin = new NpgsqlConnection(postgres.AdminConnectionString);
await admin.OpenAsync();
await using (var terminate = admin.CreateCommand())
{
terminate.CommandText =
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = @name AND pid <> pg_backend_pid()";
terminate.Parameters.AddWithValue("name", databaseName);
await terminate.ExecuteNonQueryAsync();
}
await using var drop = admin.CreateCommand();
drop.CommandText = $"DROP DATABASE IF EXISTS \"{databaseName}\"";
await drop.ExecuteNonQueryAsync();
}
}
private static DateTimeOffset TruncateToMicroseconds(DateTimeOffset value) =>
new(value.Ticks - (value.Ticks % (TimeSpan.TicksPerMillisecond / 1000)), value.Offset);
}
@@ -0,0 +1,556 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Application.Migration;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Migration;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Migration;
/// <summary>
/// The LeafWiki filesystem-Markdown importer end to end (docs/17-leafwiki-migration.md,
/// docs/27-acceptance-matrix.md M4-A04..A08): discovery/fingerprint, preflight without
/// target mutation, hierarchy/link/attachment-preserving execution, rerun idempotency,
/// rollback that preserves user edits, and source immutability. Every fixture here is
/// synthetic, written fresh per test into an isolated temp directory — never a real
/// LeafWiki snapshot (docs/09-data-safety, docs/22-testing-strategy.md).
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class LeafWikiImportTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private string CreateSourceDirectory()
{
var path = Path.Combine(Factory.ImportsDirectory, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private string RelativeToImportsRoot(string absolutePath) =>
Path.GetRelativePath(Factory.ImportsDirectory, absolutePath).Replace('\\', '/');
private static void WriteFile(string root, string relativePath, string content)
{
var full = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
[Fact]
public async Task Preflight_discovers_nested_pages_and_never_mutates_the_target()
{
var source = CreateSourceDirectory();
WriteFile(source, "index.md", "---\ntitle: Startpagina\n---\nWelkom.");
WriteFile(source, "school/index.md", "# School\nOverzicht van schoolinhoud.");
WriteFile(source, "school/wiskunde.md", "# Wiskunde\nFormules en oefeningen.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var beforeRootsResponse = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(beforeRootsResponse);
var beforeCount = beforeRootsResponse.Items.Count;
var preflightResponse = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(source), $"test-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.OK, preflightResponse.StatusCode);
var plan = await preflightResponse.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.Equal("Success", plan.Status);
Assert.Equal("filesystem-markdown", plan.AdapterName);
Assert.Equal(3, plan.PlannedCount);
Assert.Contains(plan.Items, item => item.ProposedTitle == "Startpagina" && item.ProposedParentSourceIdentity == null);
Assert.Contains(plan.Items, item => item.ProposedTitle == "School" && item.ProposedParentSourceIdentity == null);
Assert.Contains(plan.Items, item => item.ProposedTitle == "Wiskunde" && item.ProposedParentSourceIdentity == "school/index.md");
// No target mutation during preflight.
var afterRootsResponse = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(afterRootsResponse);
Assert.Equal(beforeCount, afterRootsResponse.Items.Count);
}
[Fact]
public async Task A_source_root_absolute_asset_link_is_recognized_and_imported_not_reported_missing()
{
// A real LeafWiki export commonly writes asset links as source-root-absolute
// ("/assets/<id>/file.png"), unlike every other fixture in this file (and
// CutoverRehearsalTests' own comment on this exact point), which only ever
// uses an already-relative reference. Found against a real export, not a
// hypothetical — this is the real regression test for that fix.
byte[] pngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
var source = CreateSourceDirectory();
WriteFile(source, "index.md", "# Netwerkdiagram\n![Diagram](/assets/abc123/diagram.png)\n");
var assetPath = Path.Combine(source, "assets", "abc123", "diagram.png");
Directory.CreateDirectory(Path.GetDirectoryName(assetPath)!);
File.WriteAllBytes(assetPath, pngBytes);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflightResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(source), $"asset-{Guid.NewGuid():N}"));
var plan = await preflightResponse.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.DoesNotContain(plan.Warnings, warning => warning.Contains("niet gevonden", StringComparison.Ordinal));
var executeResponse = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode);
var executed = await executeResponse.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(executed);
Assert.Empty(executed.Diagnostics);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
var page = Assert.Single(roots!.Items, item => item.Title == "Netwerkdiagram");
var attachments = await client.GetFromJsonAsync<AttachmentListResponse>($"/api/v1/pages/{page.Id}/attachments");
Assert.NotNull(attachments);
Assert.Single(attachments.Items);
}
[Fact]
public async Task Execute_creates_the_hierarchy_resolves_wiki_links_and_leaves_unresolved_links_as_reported()
{
var source = CreateSourceDirectory();
WriteFile(source, "school/index.md", "# School\nZie [[Wiskunde]] en [[Onbestaande pagina]].");
WriteFile(source, "school/wiskunde.md", "# Wiskunde\nFormules.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflightResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(source), $"test-{Guid.NewGuid():N}"));
var plan = await preflightResponse.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.NotNull(plan.JobId);
var executeResponse = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode);
var executeResult = await executeResponse.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(executeResult);
Assert.Equal(2, executeResult.Imported);
Assert.Equal(0, executeResult.Failed);
Assert.True(executeResult.SourceUnchangedAfterExecution);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
var schoolPage = Assert.Single(roots.Items, item => item.Title == "School");
var schoolDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{schoolPage.Id}");
Assert.NotNull(schoolDetail);
// A resolved internal link renders as a real anchor; the unresolved one stays
// literal bracket text (docs/11-markdown-and-linking.md, proven again here on
// imported content).
Assert.Contains("<a href=", schoolDetail.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("[[Onbestaande pagina]]", schoolDetail.RenderedHtml, StringComparison.Ordinal);
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{schoolPage.Id}/children");
Assert.NotNull(children);
Assert.Contains(children.Items, item => item.Title == "Wiskunde");
}
[Fact]
public async Task Rerunning_the_same_source_is_idempotent_and_skips_unchanged_items()
{
var source = CreateSourceDirectory();
WriteFile(source, "pagina.md", "# Enkele pagina\nInhoud.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
async Task<ExecuteImportResponse> RunOnceAsync(string idempotencyKey)
{
var preflight = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), idempotencyKey));
var plan = await preflight.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
var execute = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
var result = await execute.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(result);
return result;
}
var first = await RunOnceAsync($"run1-{Guid.NewGuid():N}");
Assert.Equal(1, first.Imported);
Assert.Equal(0, first.Skipped);
var second = await RunOnceAsync($"run2-{Guid.NewGuid():N}");
Assert.Equal(0, second.Imported);
Assert.Equal(1, second.Skipped);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
Assert.Single(roots.Items, item => item.Title == "Enkele pagina");
}
[Fact]
public async Task Rollback_removes_an_unedited_imported_page_but_preserves_one_the_user_edited_afterward()
{
var source = CreateSourceDirectory();
WriteFile(source, "ongewijzigd.md", "# Ongewijzigd\nBlijft zoals geïmporteerd.");
WriteFile(source, "bewerkt.md", "# Bewerkt\nWordt na import aangepast.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflight = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"rb-{Guid.NewGuid():N}"));
var plan = await preflight.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
var editedPage = Assert.Single(roots.Items, item => item.Title == "Bewerkt");
var editedDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{editedPage.Id}");
Assert.NotNull(editedDetail);
// The user edits the imported page before the rollback happens.
await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/pages/{editedPage.Id}",
new UpdatePageRequest("Bewerkt door gebruiker", editedDetail.Markdown, "WikiPage", "Active", null),
editedDetail.ETag);
var rollbackResponse = await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/admin/imports/{plan.JobId}/rollback", (object?)null);
Assert.Equal(HttpStatusCode.OK, rollbackResponse.StatusCode);
var rollback = await rollbackResponse.Content.ReadFromJsonAsync<RollbackImportResponse>();
Assert.NotNull(rollback);
Assert.Equal(1, rollback.RemovedPages);
Assert.Equal(1, rollback.PreservedPages);
var rootsAfterRollback = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(rootsAfterRollback);
Assert.DoesNotContain(rootsAfterRollback.Items, item => item.Title == "Ongewijzigd");
Assert.Contains(rootsAfterRollback.Items, item => item.Title == "Bewerkt door gebruiker");
}
[Fact]
public async Task A_symlink_escaping_the_source_root_is_recorded_but_never_followed()
{
var source = CreateSourceDirectory();
var outsideTarget = Path.Combine(Path.GetTempPath(), "ikh-outside-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideTarget);
File.WriteAllText(Path.Combine(outsideTarget, "geheim.md"), "# Geheim\nMag niet gelezen worden.");
WriteFile(source, "normale-pagina.md", "# Normale pagina\nInhoud.");
try
{
File.CreateSymbolicLink(Path.Combine(source, "ontsnapping.md"), Path.Combine(outsideTarget, "geheim.md"));
}
catch (IOException)
{
// Symlink creation can require elevated privileges on some Windows
// configurations; skip this specific assertion rather than fail the whole
// suite for an environment limitation unrelated to the importer's logic.
return;
}
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflight = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"sym-{Guid.NewGuid():N}"));
var plan = await preflight.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
// The symlink is not itself a Markdown source item, so it's excluded from the
// plan entirely — the reader never opens a symlink target at all (recorded,
// never followed), so the outside file's content cannot appear anywhere here.
Assert.DoesNotContain(plan.Items, item => item.SourceIdentity.Contains("ontsnapping", StringComparison.Ordinal));
Assert.Single(plan.Items, item => item.SourceIdentity == "normale-pagina.md");
}
[Fact]
public async Task Preflight_refuses_a_linked_source_root_that_points_outside_imports()
{
Directory.CreateDirectory(Factory.ImportsDirectory);
var outsideTarget = Path.Combine(Path.GetTempPath(), "ikh-outside-root-" + Guid.NewGuid().ToString("N"));
var linkedSource = Path.Combine(Factory.ImportsDirectory, "linked-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outsideTarget);
WriteFile(outsideTarget, "outside-secret.md", "# Buiten de imports-map\nDeze bron mag niet worden ontdekt.");
var outsideFile = Path.Combine(outsideTarget, "outside-secret.md");
var originalBytes = File.ReadAllBytes(outsideFile);
try
{
Assert.True(
TryCreateDirectoryLink(linkedSource, outsideTarget),
"The test host must support a directory symlink or junction to exercise the source-root boundary.");
Assert.NotNull(new DirectoryInfo(linkedSource).LinkTarget);
Assert.True((File.GetAttributes(linkedSource) & FileAttributes.ReparsePoint) != 0);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflight = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(linkedSource), $"linked-root-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, preflight.StatusCode);
var responseBody = await preflight.Content.ReadAsStringAsync();
Assert.Contains("import-source-invalid", responseBody, StringComparison.Ordinal);
Assert.DoesNotContain("Deze bron mag niet worden ontdekt", responseBody, StringComparison.Ordinal);
Assert.Equal(originalBytes, File.ReadAllBytes(outsideFile));
var reader = Factory.Services.GetRequiredService<ILeafWikiSourceReader>();
await Assert.ThrowsAsync<InvalidOperationException>(
() => reader.OpenFileAsync(linkedSource, "outside-secret.md", CancellationToken.None));
Assert.Equal(originalBytes, File.ReadAllBytes(outsideFile));
}
finally
{
if (Directory.Exists(linkedSource))
{
Directory.Delete(linkedSource);
}
if (Directory.Exists(outsideTarget))
{
File.Delete(outsideFile);
Directory.Delete(outsideTarget);
}
}
}
private static bool TryCreateDirectoryLink(string linkPath, string targetPath)
{
if (!OperatingSystem.IsWindows())
{
try
{
Directory.CreateSymbolicLink(linkPath, targetPath);
return true;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
return false;
}
}
try
{
var startInfo = new ProcessStartInfo("cmd.exe")
{
UseShellExecute = false,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("/d");
startInfo.ArgumentList.Add("/c");
startInfo.ArgumentList.Add("mklink");
startInfo.ArgumentList.Add("/J");
startInfo.ArgumentList.Add(linkPath);
startInfo.ArgumentList.Add(targetPath);
using var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
process.WaitForExit();
return process.ExitCode == 0;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception)
{
return false;
}
}
[Fact]
public async Task A_synthetic_sqlite_source_with_the_recognized_schema_is_imported_via_the_sqlite_adapter()
{
var source = CreateSourceDirectory();
var databasePath = Path.Combine(source, "leafwiki.sqlite");
CreateSyntheticSqliteFixture(databasePath);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflight = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"sqlite-{Guid.NewGuid():N}"));
var plan = await preflight.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.Equal("synthetic-sqlite-v1", plan.AdapterName);
Assert.Equal(2, plan.PlannedCount);
var execute = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
var result = await execute.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(result);
Assert.Equal(2, result.Imported);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
Assert.Contains(roots.Items, item => item.Title == "SQLite-hoofdpagina");
}
[Fact]
public async Task An_unrecognized_sqlite_schema_is_a_preflight_blocker_not_a_guessed_mapping()
{
var source = CreateSourceDirectory();
var databasePath = Path.Combine(source, "unknown.sqlite");
CreateUnknownSchemaSqliteFixture(databasePath);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var preflight = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/imports/preflight", new PreflightImportRequest(RelativeToImportsRoot(source), $"unknown-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, preflight.StatusCode);
}
[Fact]
public async Task Only_an_administrator_can_run_a_preflight()
{
using var ownerClient = Factory.CreateClient();
await SetupHelper.LoginAsync(ownerClient, OwnerEmail, OwnerPassword);
var editorEmail = $"redacteur-import-{Guid.NewGuid():N}@itworx.test";
const string editorPassword = "correct-horse-battery-staple";
await SetupHelper.PostWithCsrfAsync(
ownerClient,
"/api/v1/admin/users",
new ItWorx.KnowledgeHub.Contracts.Admin.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/imports/preflight", new PreflightImportRequest(".", $"forbidden-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task A_bearer_token_with_imports_scopes_can_preflight_and_execute_a_real_import()
{
var source = CreateSourceDirectory();
WriteFile(source, "index.md", "# Startpagina\nWelkom.");
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/service-accounts",
new CreateServiceAccountRequest("Migratie-bot", "Testaccount.", [Scopes.ImportsExecute, Scopes.ImportsRead]));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(created);
using var bearerClient = Factory.CreateClient();
bearerClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", created.RawToken);
var preflightResponse = await bearerClient.PostAsJsonAsync(
"/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(source), $"bearer-import-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.OK, preflightResponse.StatusCode);
var plan = await preflightResponse.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
Assert.NotNull(plan.JobId);
var jobResponse = await bearerClient.GetAsync($"/api/v1/admin/imports/{plan.JobId}");
Assert.Equal(HttpStatusCode.OK, jobResponse.StatusCode);
var executeResponse = await bearerClient.PostAsync(
$"/api/v1/admin/imports/{plan.JobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
content: null);
Assert.Equal(HttpStatusCode.OK, executeResponse.StatusCode);
var executed = await executeResponse.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(executed);
Assert.True(executed.Imported > 0);
// imports:execute/imports:read never implicitly grant pages:read — scopes are
// narrow and additive, never bundled (M6-A02), same property already proven
// for pages:write/projects:write in ServiceAccountTests.cs.
var pagesResponse = await bearerClient.GetAsync("/api/v1/pages/roots");
Assert.Equal(HttpStatusCode.Forbidden, pagesResponse.StatusCode);
}
[Fact]
public async Task A_bearer_token_without_imports_scopes_cannot_run_a_preflight_vertical_privilege_escalation()
{
using var adminClient = Factory.CreateClient();
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/service-accounts",
new CreateServiceAccountRequest("Read-only-bot", "Testaccount.", [Scopes.PagesRead]));
var created = await createResponse.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(created);
using var bearerClient = Factory.CreateClient();
bearerClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", created.RawToken);
var response = await bearerClient.PostAsJsonAsync(
"/api/v1/admin/imports/preflight", new PreflightImportRequest(".", $"escalation-{Guid.NewGuid():N}"));
Assert.True(
response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected the escalation attempt to be denied, but got {response.StatusCode}.");
}
private static void CreateSyntheticSqliteFixture(string path)
{
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText =
"""
CREATE TABLE leafwiki_meta (key TEXT PRIMARY KEY, value TEXT);
INSERT INTO leafwiki_meta (key, value) VALUES ('schema', 'itworx-synthetic-leafwiki-v1');
CREATE TABLE pages (
id INTEGER PRIMARY KEY,
parent_id INTEGER NULL REFERENCES pages(id),
title TEXT NOT NULL,
body_markdown TEXT NOT NULL,
created_at TEXT NOT NULL
);
INSERT INTO pages (id, parent_id, title, body_markdown, created_at)
VALUES (1, NULL, 'SQLite-hoofdpagina', '# Hoofdpagina', '2020-01-01');
INSERT INTO pages (id, parent_id, title, body_markdown, created_at)
VALUES (2, 1, 'SQLite-subpagina', '# Subpagina', '2020-01-02');
""";
command.ExecuteNonQuery();
}
private static void CreateUnknownSchemaSqliteFixture(string path)
{
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = false }.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TABLE articles (id INTEGER PRIMARY KEY, headline TEXT);";
command.ExecuteNonQuery();
}
}
@@ -0,0 +1,317 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Migration;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Migration;
/// <summary>
/// The version-validated LeafWiki v5 filesystem adapter end to end
/// (docs/17-leafwiki-migration.md; docs/adr/ADR-032-leafwiki-v5-import-fidelity.md).
/// Every fixture is synthetic and written fresh per test — it reproduces the *shape* of
/// a real LeafWiki v5 export (schema.json, leafwiki_* frontmatter including
/// YAML-escaped titles, .order.json sibling ordering, source-root-absolute
/// /assets/&lt;page-id&gt;/ image links) without containing any real user content.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class LeafWikiV5ImportTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private string CreateSourceDirectory()
{
var path = Path.Combine(Factory.ImportsDirectory, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private string RelativeToImportsRoot(string absolutePath) =>
Path.GetRelativePath(Factory.ImportsDirectory, absolutePath).Replace('\\', '/');
private static void WriteFile(string root, string relativePath, string content)
{
var full = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
private static void WriteBinary(string root, string relativePath, byte[] content)
{
var full = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllBytes(full, content);
}
/// <summary>
/// Builds a source shaped exactly like the real export: a schema.json version
/// marker, index.md-per-directory, leafwiki_id/leafwiki_title frontmatter (one of
/// them a YAML double-quoted scalar with a \U escape, one of them title-only with
/// no body at all), .order.json files whose order deliberately contradicts
/// alphabetical order, and a source-root-absolute asset reference.
/// </summary>
private string CreateV5Source()
{
var source = CreateSourceDirectory();
WriteFile(source, "schema.json", "{\n \"version\": 5\n}");
WriteFile(source, ".order.json", "{\"ordered_ids\": [\"netw-01\", \"alg-01\"]}");
// "Netwerken" sorts after "Algemeen" alphabetically but is listed first.
WriteFile(source, "netwerken/index.md",
"---\nleafwiki_id: netw-01\nleafwiki_title: \"\\U0001F539 Netwerken\"\n---\n");
WriteFile(source, "netwerken/.order.json", "{\"ordered_ids\": [\"netw-vlan\", \"netw-stp\"]}");
WriteFile(source, "netwerken/stp.md",
"---\nleafwiki_id: netw-stp\nleafwiki_title: Spanning Tree\n---\n# STP – ANDERE KOP\nBlokkeert lussen.");
WriteFile(source, "netwerken/vlan.md",
"---\nleafwiki_id: netw-vlan\n---\n# VLAN's\n![diagram](/assets/netw-vlan/diagram.png)\n");
WriteFile(source, "algemeen/index.md",
"---\nleafwiki_id: alg-01\nleafwiki_title: Algemeen\n---\nInleiding.");
WriteBinary(source, "assets/netw-vlan/diagram.png", PngBytes);
return source;
}
private async Task<ImportPreflightResponse> PreflightAsync(HttpClient client, string source)
{
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/imports/preflight",
new PreflightImportRequest(RelativeToImportsRoot(source), $"v5-{Guid.NewGuid():N}"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var plan = await response.Content.ReadFromJsonAsync<ImportPreflightResponse>();
Assert.NotNull(plan);
return plan;
}
private async Task<ExecuteImportResponse> ExecuteAsync(HttpClient client, string source, Guid? jobId)
{
Assert.NotNull(jobId);
var response = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/admin/imports/{jobId}/execute?sourceRelativePath={Uri.EscapeDataString(RelativeToImportsRoot(source))}",
(object?)null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var executed = await response.Content.ReadFromJsonAsync<ExecuteImportResponse>();
Assert.NotNull(executed);
return executed;
}
[Fact]
public async Task A_leafwiki_v5_export_is_recognized_and_titled_from_its_own_frontmatter()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
Assert.Equal("Success", plan.Status);
Assert.Equal("leafwiki-filesystem-v5", plan.AdapterName);
// The escaped YAML scalar becomes a real title, not the literal "\U0001F539".
Assert.Contains(plan.Items, item => item.ProposedTitle == "\U0001F539 Netwerken");
Assert.DoesNotContain(plan.Items, item => item.ProposedTitle.Contains("\\U0001", StringComparison.Ordinal));
// LeafWiki's stored title wins over a body heading that says something else,
// and a body with no heading at all is still correctly titled (the generic
// fallback adapter would have called this one "Index").
Assert.Contains(plan.Items, item => item.ProposedTitle == "Spanning Tree");
Assert.DoesNotContain(plan.Items, item => item.ProposedTitle == "Index");
// No frontmatter title at all: the body heading is still used.
Assert.Contains(plan.Items, item => item.ProposedTitle == "VLAN's");
}
[Fact]
public async Task Imported_siblings_follow_the_order_files_not_alphabetical_order()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.Equal("Success", executed.Status);
Assert.Equal(0, executed.Failed);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.NotNull(roots);
var importedRoots = roots.Items
.Where(item => item.Title is "\U0001F539 Netwerken" or "Algemeen")
.Select(item => item.Title)
.ToList();
Assert.Equal(["\U0001F539 Netwerken", "Algemeen"], importedRoots);
var networks = Assert.Single(roots.Items, item => item.Title == "\U0001F539 Netwerken");
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{networks.Id}/children");
Assert.NotNull(children);
Assert.Equal(["VLAN's", "Spanning Tree"], children.Items.Select(item => item.Title).ToList());
}
[Fact]
public async Task An_imported_image_link_points_at_the_attachment_the_import_created()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.Empty(executed.Diagnostics);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
var networks = Assert.Single(roots!.Items, item => item.Title == "\U0001F539 Netwerken");
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{networks.Id}/children");
var vlan = Assert.Single(children!.Items, item => item.Title == "VLAN's");
var attachments = await client.GetFromJsonAsync<AttachmentListResponse>($"/api/v1/pages/{vlan.Id}/attachments");
var attachment = Assert.Single(attachments!.Items);
var detail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{vlan.Id}");
Assert.NotNull(detail);
// The stored Markdown must reference the attachment this import created —
// the source path "/assets/netw-vlan/diagram.png" resolves to nothing here.
Assert.Contains($"/api/v1/attachments/{attachment.Id}/content", detail.Markdown, StringComparison.Ordinal);
Assert.DoesNotContain("/assets/netw-vlan/diagram.png", detail.Markdown, StringComparison.Ordinal);
// And the rendered HTML really serves those bytes back.
Assert.Contains($"/api/v1/attachments/{attachment.Id}/content", detail.RenderedHtml, StringComparison.Ordinal);
var content = await client.GetAsync(new Uri($"/api/v1/attachments/{attachment.Id}/content", UriKind.Relative));
Assert.Equal(HttpStatusCode.OK, content.StatusCode);
Assert.Equal(PngBytes, await content.Content.ReadAsByteArrayAsync());
}
[Fact]
public async Task A_page_whose_body_is_only_frontmatter_is_imported_as_an_empty_page_not_skipped()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.Equal(4, plan.PlannedCount);
Assert.Equal(4, executed.Imported);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
var networks = Assert.Single(roots!.Items, item => item.Title == "\U0001F539 Netwerken");
var detail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{networks.Id}");
Assert.Equal(string.Empty, detail!.Markdown);
}
[Fact]
public async Task A_body_heading_that_only_repeats_the_title_is_not_imported_twice()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
await ExecuteAsync(client, source, plan.JobId);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
var networks = Assert.Single(roots!.Items, item => item.Title == "🔹 Netwerken");
var children = await client.GetFromJsonAsync<PageChildrenResponse>($"/api/v1/pages/{networks.Id}/children");
// "# VLAN's" repeats the page title exactly: the reader would otherwise see the
// same title twice, and the document would have two h1 elements.
var vlan = Assert.Single(children!.Items, item => item.Title == "VLAN's");
var vlanDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{vlan.Id}");
Assert.DoesNotContain("# VLAN's", vlanDetail!.Markdown, StringComparison.Ordinal);
// The rendered body no longer carries an h1 of its own; the application renders
// the page title as the document heading above it.
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(vlanDetail.RenderedHtml, "<h1"));
// "# STP – ANDERE KOP" says something different from the title "Spanning Tree":
// that is content, and it is kept.
var stp = Assert.Single(children.Items, item => item.Title == "Spanning Tree");
var stpDetail = await client.GetFromJsonAsync<PageDetail>($"/api/v1/pages/{stp.Id}");
Assert.Contains("# STP – ANDERE KOP", stpDetail!.Markdown, StringComparison.Ordinal);
Assert.Single(System.Text.RegularExpressions.Regex.Matches(stpDetail.RenderedHtml, "<h1"));
}
[Fact]
public async Task An_unsupported_declared_schema_version_falls_back_instead_of_guessing()
{
var source = CreateSourceDirectory();
WriteFile(source, "schema.json", "{\"version\": 99}");
WriteFile(source, "pagina.md", "---\nleafwiki_id: x1\nleafwiki_title: Toekomstige versie\n---\n# Kop\nTekst.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
// The v5 adapter refuses an unknown declared version; the generic Markdown
// fallback still imports what it safely can, and says so.
Assert.Equal("filesystem-markdown", plan.AdapterName);
Assert.Contains(plan.Items, item => item.ProposedTitle == "Kop");
}
[Fact]
public async Task A_plain_markdown_source_without_leafwiki_frontmatter_still_uses_the_generic_adapter()
{
var source = CreateSourceDirectory();
WriteFile(source, "index.md", "---\ntitle: Startpagina\n---\nWelkom.");
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
Assert.Equal("filesystem-markdown", plan.AdapterName);
}
[Fact]
public async Task Rerunning_a_v5_import_is_idempotent()
{
var source = CreateV5Source();
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var firstPlan = await PreflightAsync(client, source);
var first = await ExecuteAsync(client, source, firstPlan.JobId);
Assert.Equal(4, first.Imported);
var secondPlan = await PreflightAsync(client, source);
var second = await ExecuteAsync(client, source, secondPlan.JobId);
Assert.Equal(0, second.Imported);
Assert.Equal(4, second.Skipped);
var roots = await client.GetFromJsonAsync<PageChildrenResponse>("/api/v1/pages/roots");
Assert.Single(roots!.Items, item => item.Title == "\U0001F539 Netwerken");
}
[Fact]
public async Task The_source_is_never_modified_by_a_v5_import()
{
var source = CreateV5Source();
var before = FingerprintDirectory(source);
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var plan = await PreflightAsync(client, source);
var executed = await ExecuteAsync(client, source, plan.JobId);
Assert.True(executed.SourceUnchangedAfterExecution);
Assert.Equal(before, FingerprintDirectory(source));
}
private static string FingerprintDirectory(string root)
{
var builder = new StringBuilder();
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories).OrderBy(path => path, StringComparer.Ordinal))
{
builder.Append(Path.GetRelativePath(root, file).Replace('\\', '/'))
.Append('=')
.Append(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(file))))
.Append('\n');
}
return builder.ToString();
}
}
@@ -0,0 +1,168 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using ItWorx.KnowledgeHub.Contracts.Attachments;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.Contracts.Migration;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.Migration;
[Collection(PostgresTestGroup.Name)]
public sealed class SourceBatchPurgeTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03];
private const string BatchId = "canvas-20260801T134020Z";
[Fact]
public async Task The_administrator_only_endpoint_rejects_anonymous_requests()
{
using var client = Factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/v1/admin/source-batches/purge/dry-run",
new SourceBatchPurgeRequest(BatchId, "canvas", [new SourceBatchPurgePageEvidence(Guid.NewGuid(), new string('0', 64))]));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Dry_run_blocks_when_a_canvas_candidate_is_not_in_the_reviewed_batch_list()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var selected = await CreatePageAsync(client, "Geselecteerde bron", ["source:canvas", "course:test"]);
await CreatePageAsync(client, "Niet geselecteerde bron", ["source:canvas", "course:test"]);
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/source-batches/purge/dry-run",
new SourceBatchPurgeRequest(BatchId, "canvas", [await EvidenceAsync(selected)]));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var dryRun = await response.Content.ReadFromJsonAsync<SourceBatchPurgeDryRunResponse>();
Assert.NotNull(dryRun);
Assert.False(dryRun.Safe);
Assert.Contains(dryRun.Blockers, blocker => blocker.Contains("buiten de gecontroleerde batchlijst", StringComparison.Ordinal));
}
[Fact]
public async Task Dry_run_blocks_when_a_selected_page_no_longer_matches_its_reviewed_content_hash()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client, "Canvasbron", ["source:canvas", "course:test"]);
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/source-batches/purge/dry-run",
new SourceBatchPurgeRequest(BatchId, "canvas", [new SourceBatchPurgePageEvidence(pageId, new string('0', 64))]));
var dryRun = await response.Content.ReadFromJsonAsync<SourceBatchPurgeDryRunResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(dryRun);
Assert.False(dryRun.Safe);
Assert.Contains(dryRun.Blockers, blocker => blocker.Contains("gevalideerde bronhash", StringComparison.Ordinal));
}
[Fact]
public async Task A_confirmed_safe_purge_removes_the_selected_page_references_and_verified_orphan_blob()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var pageId = await CreatePageAsync(client, "Canvasbron", ["source:canvas", "course:test"]);
var upload = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{pageId}/attachments", PngBytes, "canvas.png", "image/png");
Assert.Equal(HttpStatusCode.OK, upload.StatusCode);
var dryRun = await DryRunAsync(client, [pageId]);
Assert.True(dryRun.Safe);
Assert.Equal(1, dryRun.Pages);
Assert.Equal(1, dryRun.AttachmentReferences);
Assert.Equal(1, dryRun.OrphanedBlobs);
var execute = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/source-batches/purge/execute",
new ExecuteSourceBatchPurgeRequest(BatchId, "canvas", [await EvidenceAsync(pageId)], dryRun.ConfirmationHash));
Assert.Equal(HttpStatusCode.OK, execute.StatusCode);
var result = await execute.Content.ReadFromJsonAsync<SourceBatchPurgeResponse>();
Assert.NotNull(result);
Assert.Equal("purged", result.Status);
Assert.Equal(1, result.RemovedPages);
Assert.Equal(1, result.RemovedAttachmentReferences);
Assert.Equal(1, result.RemovedBlobs);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/v1/pages/{pageId}")).StatusCode);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
Assert.Equal(0, await db.AttachmentReferences.CountAsync());
Assert.Equal(0, await db.AttachmentBlobs.CountAsync());
var sha256 = Convert.ToHexString(SHA256.HashData(PngBytes)).ToLowerInvariant();
Assert.False(File.Exists(Path.Combine(Factory.AttachmentsDirectory, sha256[..2], sha256[2..4], sha256)));
}
[Fact]
public async Task A_shared_blob_is_retained_when_a_non_batch_page_still_references_it()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var canvasPageId = await CreatePageAsync(client, "Canvasbron", ["source:canvas", "course:test"]);
var retainedPageId = await CreatePageAsync(client, "Infrastructuur", ["infrastructure"]);
var canvasUpload = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{canvasPageId}/attachments", PngBytes, "canvas.png", "image/png");
var retainedUpload = await SetupHelper.PostMultipartWithCsrfAsync(client, $"/api/v1/pages/{retainedPageId}/attachments", PngBytes, "infra.png", "image/png");
Assert.Equal(HttpStatusCode.OK, canvasUpload.StatusCode);
Assert.Equal(HttpStatusCode.OK, retainedUpload.StatusCode);
var retainedAttachment = await retainedUpload.Content.ReadFromJsonAsync<AttachmentSummary>();
Assert.NotNull(retainedAttachment);
var dryRun = await DryRunAsync(client, [canvasPageId]);
Assert.True(dryRun.Safe);
Assert.Equal(0, dryRun.OrphanedBlobs);
Assert.Equal(1, dryRun.SharedBlobs);
var execute = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/source-batches/purge/execute",
new ExecuteSourceBatchPurgeRequest(BatchId, "canvas", [await EvidenceAsync(canvasPageId)], dryRun.ConfirmationHash));
var result = await execute.Content.ReadFromJsonAsync<SourceBatchPurgeResponse>();
Assert.NotNull(result);
Assert.Equal(HttpStatusCode.OK, execute.StatusCode);
Assert.Equal(0, result.RemovedBlobs);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync($"/api/v1/attachments/{retainedAttachment.Id}/content")).StatusCode);
}
private static async Task<Guid> CreatePageAsync(HttpClient client, string title, IReadOnlyList<string> tags)
{
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/pages",
new CreatePageRequest(null, title, "WikiPage", "<!-- managed-source:start -->\nBron\n<!-- managed-source:end -->", null, tags));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
return page.Id;
}
private async Task<SourceBatchPurgeDryRunResponse> DryRunAsync(HttpClient client, IReadOnlyList<Guid> pageIds)
{
var response = await SetupHelper.PostWithCsrfAsync(
client,
"/api/v1/admin/source-batches/purge/dry-run",
new SourceBatchPurgeRequest(BatchId, "canvas", await Task.WhenAll(pageIds.Select(pageId => EvidenceAsync(pageId)))));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return await response.Content.ReadFromJsonAsync<SourceBatchPurgeDryRunResponse>()
?? throw new InvalidOperationException("Geen dry-run ontvangen.");
}
private async Task<SourceBatchPurgePageEvidence> EvidenceAsync(Guid pageId)
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var contentHash = await db.Pages
.Where(page => page.Id == pageId)
.Select(page => page.ContentHash)
.SingleAsync();
return new SourceBatchPurgePageEvidence(pageId, contentHash);
}
}