Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
+212
@@ -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".
|
||||
"\n\n" +
|
||||
"\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));
|
||||
}
|
||||
}
|
||||
+192
@@ -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\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();
|
||||
}
|
||||
}
|
||||
+317
@@ -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/<page-id>/ 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\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();
|
||||
}
|
||||
}
|
||||
+168
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user