557 lines
27 KiB
C#
557 lines
27 KiB
C#
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();
|
|
}
|
|
}
|