Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -0,0 +1,28 @@
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Contracts.Activity;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class ActivityFeedTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Activity_feed_returns_real_append_only_audit_events_in_reverse_chronological_order()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
var create = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/pages", new CreatePageRequest(null, "Auditbare pagina", "WikiPage", "Inhoud.", null));
|
||||
create.EnsureSuccessStatusCode();
|
||||
|
||||
var activity = await client.GetFromJsonAsync<ActivityListResponse>("/api/v1/activity?limit=20");
|
||||
Assert.NotNull(activity);
|
||||
var pageCreated = Assert.Single(activity.Items, item => item.Action == "page.created");
|
||||
Assert.Equal("page", pageCreated.TargetType);
|
||||
Assert.Contains("Auditbare pagina", pageCreated.Summary, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("Inhoud.", pageCreated.Summary, StringComparison.Ordinal);
|
||||
Assert.Equal(activity.Items.OrderByDescending(item => item.OccurredAt).Select(item => item.Id), activity.Items.Select(item => item.Id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Operations;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
/// <summary>Proves M7-A03 ("A full backup captures database, attachments, manifest and
|
||||
/// integrity hashes") against a real running application and real PostgreSQL, not a
|
||||
/// mock.</summary>
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class BackupServiceTests(PostgresContainerFixture postgres)
|
||||
: OwnerSetupTestBase(postgres, new Dictionary<string, string?> { ["KnowledgeHub:Backup:RetainedCount"] = "2" })
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_backup_captures_every_table_and_verifies_its_own_hashes()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/pages", new CreatePageRequest(null, "Back-uptestpagina", "WikiPage", "Inhoud.", null));
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
|
||||
var summary = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(summary.TableCount > 0);
|
||||
Assert.True(summary.TotalRowCount > 0);
|
||||
|
||||
var manifest = await backups.GetManifestAsync(summary.BackupId, CancellationToken.None);
|
||||
Assert.NotNull(manifest);
|
||||
var pagesTable = Assert.Single(manifest.Tables, table => table.TableName == "pages");
|
||||
Assert.True(pagesTable.RowCount >= 1);
|
||||
Assert.False(string.IsNullOrWhiteSpace(pagesTable.Sha256));
|
||||
// Never a migration-bookkeeping table — that is recreated by the target's own
|
||||
// startup migration, not restored as user data (ADR-016 decision 2).
|
||||
Assert.DoesNotContain(manifest.Tables, table => table.TableName == "__EFMigrationsHistory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_backup_includes_uploaded_attachments_with_verified_hashes()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
var pageResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/pages", new CreatePageRequest(null, "Pagina met bijlage", "WikiPage", "Inhoud.", null));
|
||||
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
|
||||
Assert.NotNull(page);
|
||||
|
||||
var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync(
|
||||
client,
|
||||
$"/api/v1/pages/{page.Id}/attachments",
|
||||
"Testbestand voor back-up."u8.ToArray(),
|
||||
"backup-test.txt",
|
||||
"text/plain");
|
||||
Assert.True(uploadResponse.IsSuccessStatusCode);
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
var summary = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, summary.AttachmentCount);
|
||||
var manifest = await backups.GetManifestAsync(summary.BackupId, CancellationToken.None);
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Single(manifest.Attachments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Listing_backups_returns_newest_first_and_retention_prunes_older_ones()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
|
||||
var first = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
var second = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
var third = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
|
||||
var list = await backups.ListBackupsAsync(CancellationToken.None);
|
||||
|
||||
// Configured KnowledgeHub:Backup:RetainedCount = 2 for this test class.
|
||||
Assert.Equal(2, list.Count);
|
||||
Assert.Equal(third.BackupId, list[0].BackupId);
|
||||
Assert.Equal(second.BackupId, list[1].BackupId);
|
||||
Assert.DoesNotContain(list, entry => entry.BackupId == first.BackupId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Operations;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
/// <summary>Proves M7-A05 ("Health endpoints distinguish liveness, readiness and
|
||||
/// degraded dependencies") against real PostgreSQL and a real filesystem, not mocks.
|
||||
/// </summary>
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class HealthCheckTests(PostgresContainerFixture postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_fully_migrated_reachable_database_is_healthy()
|
||||
{
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
|
||||
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
|
||||
.UseNpgsql(target.ConnectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
await using var db = new KnowledgeHubDbContext(options);
|
||||
var check = new DatabaseHealthCheck(db);
|
||||
|
||||
var result = await check.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unreachable_database_is_unhealthy_not_a_thrown_exception()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
|
||||
.UseNpgsql("Host=127.0.0.1;Port=1;Database=does-not-exist;Username=nobody;Password=nothing;Timeout=1")
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
await using var db = new KnowledgeHubDbContext(options);
|
||||
var check = new DatabaseHealthCheck(db);
|
||||
|
||||
var result = await check.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_database_with_pending_migrations_is_unhealthy()
|
||||
{
|
||||
var databaseName = $"health_unmigrated_{Guid.NewGuid():N}";
|
||||
await using (var admin = new Npgsql.NpgsqlConnection(postgres.AdminConnectionString))
|
||||
{
|
||||
await admin.OpenAsync();
|
||||
await using var create = new Npgsql.NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin);
|
||||
await create.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var connectionString = new Npgsql.NpgsqlConnectionStringBuilder(postgres.AdminConnectionString) { Database = databaseName }.ConnectionString;
|
||||
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
|
||||
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
await using var db = new KnowledgeHubDbContext(options);
|
||||
var check = new DatabaseHealthCheck(db);
|
||||
|
||||
// Reachable, but never migrated — must never be reported ready.
|
||||
var result = await check.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var admin = new Npgsql.NpgsqlConnection(postgres.AdminConnectionString);
|
||||
await admin.OpenAsync();
|
||||
await using var drop = new Npgsql.NpgsqlCommand($"DROP DATABASE IF EXISTS \"{databaseName}\" WITH (FORCE)", admin);
|
||||
await drop.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task All_writable_configured_paths_are_healthy()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ikh-health-writable", Guid.NewGuid().ToString("N"));
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["KnowledgeHub:Paths:Data"] = Path.Combine(root, "data"),
|
||||
["KnowledgeHub:Paths:Attachments"] = Path.Combine(root, "attachments")
|
||||
})
|
||||
.Build();
|
||||
var check = new StorageHealthCheck(configuration);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await check.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_path_that_cannot_be_created_as_a_directory_is_unhealthy()
|
||||
{
|
||||
var blockingFile = Path.Combine(Path.GetTempPath(), $"ikh-health-blocking-{Guid.NewGuid():N}.tmp");
|
||||
await File.WriteAllBytesAsync(blockingFile, [1]);
|
||||
|
||||
try
|
||||
{
|
||||
// A configured path that collides with an existing *file* can never become
|
||||
// a writable directory — a real, not simulated, storage failure.
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["KnowledgeHub:Paths:Data"] = blockingFile })
|
||||
.Build();
|
||||
var check = new StorageHealthCheck(configuration);
|
||||
|
||||
var result = await check.CheckHealthAsync(new HealthCheckContext());
|
||||
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(blockingFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
/// <summary>Proves docs/19-observability.md's "Never log ... passwords" rule with a
|
||||
/// real request through the real logging pipeline, not by code review: every log line
|
||||
/// emitted while handling a real login attempt (wrong password, so the flow visits the
|
||||
/// most log-heavy failure path) is captured and asserted to never contain the raw
|
||||
/// password value.</summary>
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class LoginRedactionTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task No_log_line_emitted_during_a_login_attempt_contains_the_raw_password()
|
||||
{
|
||||
const string secretPassword = "u3q7VeryUniquePasswordNeverLogged9Zx";
|
||||
var captured = new ConcurrentQueue<string>();
|
||||
|
||||
using var loggingFactory = Factory.WithWebHostBuilder(builder =>
|
||||
builder.ConfigureLogging(logging => logging.AddProvider(new CapturingLoggerProvider(captured))));
|
||||
using var client = loggingFactory.CreateClient();
|
||||
|
||||
// A wrong-password attempt exercises the most log-heavy path (Identity
|
||||
// failure handling, audit event, rate limiter) without needing the real
|
||||
// owner password.
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, secretPassword);
|
||||
|
||||
Assert.DoesNotContain(captured, line => line.Contains(secretPassword, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private sealed class CapturingLoggerProvider(ConcurrentQueue<string> sink) : ILoggerProvider
|
||||
{
|
||||
public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, sink);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger(string categoryName, ConcurrentQueue<string> sink) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
sink.Enqueue($"[{categoryName}] {formatter(state, exception)} {exception}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Operations;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
/// <summary>Proves M7-A04 ("A clean-target restore recreates logical data, attachments
|
||||
/// and permissions and passes integrity validation") end to end against real
|
||||
/// PostgreSQL: a real backup of a real source database, restored into a second, freshly
|
||||
/// migrated, empty database on the same server — never the source itself
|
||||
/// (docs/18-backup-restore.md: "Never test restore by overwriting the only production
|
||||
/// instance").</summary>
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class RestoreServiceTests : OwnerSetupTestBase
|
||||
{
|
||||
private readonly PostgresContainerFixture _postgres;
|
||||
|
||||
public RestoreServiceTests(PostgresContainerFixture postgres) : base(postgres)
|
||||
{
|
||||
_postgres = postgres;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_backup_restores_into_a_clean_target_with_matching_row_counts_and_attachments()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
var pageResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/pages", new CreatePageRequest(null, "Herstelde pagina", "WikiPage", "Inhoud om te herstellen.", null));
|
||||
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
|
||||
Assert.NotNull(page);
|
||||
var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync(
|
||||
client, $"/api/v1/pages/{page.Id}/attachments", "Herstelbestand."u8.ToArray(), "restore-test.txt", "text/plain");
|
||||
Assert.True(uploadResponse.IsSuccessStatusCode);
|
||||
|
||||
using var sourceScope = Factory.Services.CreateScope();
|
||||
var backupService = sourceScope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
var backup = await backupService.CreateBackupAsync(CancellationToken.None);
|
||||
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
|
||||
try
|
||||
{
|
||||
var restoreService = sourceScope.ServiceProvider.GetRequiredService<IRestoreService>();
|
||||
var report = await restoreService.RestoreAsync(
|
||||
backup.BackupId,
|
||||
new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(report.Success, string.Join("; ", report.Warnings));
|
||||
Assert.All(report.Tables, table => Assert.True(table.Matches, $"{table.TableName}: {table.RestoredRowCount}/{table.ExpectedRowCount}"));
|
||||
var pagesTable = Assert.Single(report.Tables, table => table.TableName == "pages");
|
||||
Assert.True(pagesTable.RestoredRowCount >= 1);
|
||||
Assert.Equal(1, report.AttachmentsRestored);
|
||||
Assert.Equal(report.AttachmentsExpected, report.AttachmentsRestored);
|
||||
|
||||
var restoredAttachmentFiles = Directory.GetFiles(target.AttachmentsDirectory, "*", SearchOption.AllDirectories);
|
||||
Assert.Single(restoredAttachmentFiles);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await target.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restoring_into_a_non_empty_target_is_refused()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/pages", new CreatePageRequest(null, "Bron voor niet-schone test", "WikiPage", "I.", null));
|
||||
|
||||
using var sourceScope = Factory.Services.CreateScope();
|
||||
var backupService = sourceScope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
var backup = await backupService.CreateBackupAsync(CancellationToken.None);
|
||||
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
|
||||
try
|
||||
{
|
||||
// Seed the "clean" target with a pre-existing row so it is not actually empty.
|
||||
await using (var connection = new NpgsqlConnection(target.ConnectionString))
|
||||
{
|
||||
await connection.OpenAsync();
|
||||
await using var seed = new NpgsqlCommand(
|
||||
"INSERT INTO \"public\".\"installations\" (id, instance_id, primary_locale, time_zone, product_version, schema_version) " +
|
||||
"VALUES (gen_random_uuid(), gen_random_uuid(), 'nl-BE', 'Europe/Brussels', '0.0.0', 'm7')",
|
||||
connection);
|
||||
await seed.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var restoreService = sourceScope.ServiceProvider.GetRequiredService<IRestoreService>();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => restoreService.RestoreAsync(
|
||||
backup.BackupId, new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory), CancellationToken.None));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await target.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A second, freshly migrated, empty logical database on the same shared
|
||||
/// Testcontainers Postgres server — a genuine "clean target", never the source
|
||||
/// database under test.</summary>
|
||||
internal sealed class RestoreTargetDatabase : IAsyncDisposable
|
||||
{
|
||||
private readonly PostgresContainerFixture _postgres;
|
||||
private readonly string _databaseName;
|
||||
|
||||
public string ConnectionString { get; }
|
||||
|
||||
public string AttachmentsDirectory { get; }
|
||||
|
||||
private RestoreTargetDatabase(PostgresContainerFixture postgres, string databaseName, string connectionString, string attachmentsDirectory)
|
||||
{
|
||||
_postgres = postgres;
|
||||
_databaseName = databaseName;
|
||||
ConnectionString = connectionString;
|
||||
AttachmentsDirectory = attachmentsDirectory;
|
||||
}
|
||||
|
||||
public static async Task<RestoreTargetDatabase> CreateAsync(PostgresContainerFixture postgres)
|
||||
{
|
||||
var databaseName = $"restore_target_{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;
|
||||
|
||||
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
|
||||
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
await using (var db = new KnowledgeHubDbContext(options))
|
||||
{
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
var attachmentsDirectory = Path.Combine(Path.GetTempPath(), "ikh-tests-restore-target", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(attachmentsDirectory);
|
||||
|
||||
return new RestoreTargetDatabase(postgres, databaseName, connectionString, attachmentsDirectory);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Directory.Exists(AttachmentsDirectory))
|
||||
{
|
||||
Directory.Delete(AttachmentsDirectory, recursive: true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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();
|
||||
}
|
||||
catch (NpgsqlException)
|
||||
{
|
||||
// Best-effort cleanup, same rationale as KnowledgeHubIntegrationFactory's
|
||||
// own teardown.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
/// <summary>Proves M7-A02 ("Startup refuses unsafe schema drift") against a real,
|
||||
/// freshly migrated PostgreSQL database — forward migration (the normal path) must
|
||||
/// stay silent, and a database with an applied migration this build's assembly does
|
||||
/// not know about must be refused before any data-mutating call.</summary>
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class SchemaGuardTests(PostgresContainerFixture postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_freshly_migrated_database_with_only_known_migrations_is_accepted()
|
||||
{
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
|
||||
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
|
||||
.UseNpgsql(target.ConnectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
await using var db = new KnowledgeHubDbContext(options);
|
||||
|
||||
await SchemaGuard.EnsureCompatibleAsync(db.Database, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unmigrated_brand_new_database_is_accepted_nothing_can_be_ahead_of_an_empty_history()
|
||||
{
|
||||
var databaseName = $"schema_guard_fresh_{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();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var connectionString = new NpgsqlConnectionStringBuilder(postgres.AdminConnectionString) { Database = databaseName }.ConnectionString;
|
||||
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
|
||||
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
await using var db = new KnowledgeHubDbContext(options);
|
||||
|
||||
// No exception, even though the migrations-history table does not exist yet.
|
||||
await SchemaGuard.EnsureCompatibleAsync(db.Database, CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var admin = new NpgsqlConnection(postgres.AdminConnectionString);
|
||||
await admin.OpenAsync();
|
||||
await using var drop = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{databaseName}\" WITH (FORCE)", admin);
|
||||
await drop.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_database_with_an_applied_migration_unknown_to_this_build_is_refused()
|
||||
{
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
|
||||
|
||||
// Simulate a database that is ahead of this build (e.g. rolled back to an
|
||||
// older image against a newer database) by recording a migration this
|
||||
// assembly has never heard of.
|
||||
await using (var connection = new NpgsqlConnection(target.ConnectionString))
|
||||
{
|
||||
await connection.OpenAsync();
|
||||
await using var insert = new NpgsqlCommand(
|
||||
"INSERT INTO \"public\".\"__EFMigrationsHistory\" (migration_id, product_version) VALUES (@id, @version)",
|
||||
connection);
|
||||
insert.Parameters.AddWithValue("id", "99999999999999_FutureMigrationFromAnUnreleasedBuild");
|
||||
insert.Parameters.AddWithValue("version", "99.0.0");
|
||||
await insert.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
|
||||
.UseNpgsql(target.ConnectionString, npgsql => npgsql.MigrationsAssembly(typeof(KnowledgeHubDbContext).Assembly.GetName().Name))
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
await using var db = new KnowledgeHubDbContext(options);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<SchemaDriftException>(
|
||||
() => SchemaGuard.EnsureCompatibleAsync(db.Database, CancellationToken.None));
|
||||
Assert.Contains("FutureMigrationFromAnUnreleasedBuild", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
/// <summary>docs/19-observability.md: "/api/v1/system/info" is permissioned
|
||||
/// information, distinct from the anonymous /health/live and /health/ready
|
||||
/// endpoints.</summary>
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class SystemInfoTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task An_authenticated_administrator_sees_the_current_schema_version()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
|
||||
var response = await client.GetAsync("/api/v1/system/info");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<SystemInfoPayload>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("ItWorx Knowledge Hub", payload.Product);
|
||||
Assert.Equal("m7", payload.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_anonymous_caller_is_rejected()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/system/info");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>docs/adr/ADR-029-v2-r5-final-hardening.md (brief Phase 9): the
|
||||
/// canonical release identity always includes a live-computed database migration
|
||||
/// range for whichever database the instance is actually connected to — this is
|
||||
/// never baked into the build-time manifest, so it must be non-null once
|
||||
/// migrations have run (which they always have, by the time an integration test's
|
||||
/// database is usable at all). Build-time-only fields (openApiHash, etc.) are
|
||||
/// honestly null in this test environment, since no release-manifest.json exists
|
||||
/// on a local test run — never fabricated.</summary>
|
||||
[Fact]
|
||||
public async Task The_release_identity_always_reports_a_live_database_migration_range()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
|
||||
var response = await client.GetAsync("/api/v1/system/info");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<SystemInfoWithReleasePayload>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.NotNull(payload.Release);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.Release.DatabaseMigrationRange));
|
||||
// No release-manifest.json is baked in for a local test run — this must
|
||||
// degrade to null, never a fabricated value.
|
||||
Assert.Null(payload.Release.OpenApiHash);
|
||||
}
|
||||
|
||||
private sealed record SystemInfoPayload(string Product, string SchemaVersion);
|
||||
|
||||
private sealed record SystemInfoWithReleasePayload(string Product, ReleaseIdentityPayload Release);
|
||||
|
||||
private sealed record ReleaseIdentityPayload(string? DatabaseMigrationRange, string? OpenApiHash);
|
||||
}
|
||||
Reference in new issue
Block a user