Publish curated Knowledge Hub source from 2ed833f290b439a7bc91e5fd19b042c269e3bee9
This commit is contained in:
1 parent
10f2edcd29
commit
59f15b2d08
218 files changed
+23995
-1849
No files matched your search
@@ -0,0 +1,26 @@
|
||||
using ItWorx.KnowledgeHub.Application.Migration;
|
||||
using ItWorx.KnowledgeHub.Infrastructure;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Tests;
|
||||
|
||||
public sealed class LeafWikiAdapterLifetimeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Preflight_catalog_resolves_with_scope_validation_enabled()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddKnowledgeHubInfrastructure(configuration);
|
||||
|
||||
using var provider = services.BuildServiceProvider(new ServiceProviderOptions
|
||||
{
|
||||
ValidateScopes = true
|
||||
});
|
||||
using var scope = provider.CreateScope();
|
||||
|
||||
Assert.NotNull(scope.ServiceProvider.GetRequiredService<ILeafWikiAdapterCatalog>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Net;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Api.Tests;
|
||||
|
||||
public sealed class PlatformEndpointAuthorizationTests : IClassFixture<KnowledgeHubApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public PlatformEndpointAuthorizationTests(KnowledgeHubApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_callers_cannot_read_personal_preferences()
|
||||
{
|
||||
using var response = await _client.GetAsync("/api/v1/me/preferences");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_callers_cannot_read_operator_platform_settings()
|
||||
{
|
||||
using var response = await _client.GetAsync("/api/v1/admin/platform-settings");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -46,40 +46,120 @@ public sealed class RestoreModeRunnerTests
|
||||
[Fact]
|
||||
public async Task A_successful_restore_returns_exit_code_zero()
|
||||
{
|
||||
var connectionString = "Host=restore-db;Username=restore;Password=public-fixture";
|
||||
var connectionFile = await CreateConnectionFileAsync(connectionString);
|
||||
string? receivedConnectionString = null;
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IRestoreService>(new FakeRestoreService(shouldSucceed: true))
|
||||
.AddSingleton<IRestoreService>(new FakeRestoreService(shouldSucceed: true,
|
||||
target => receivedConnectionString = target.ConnectionString))
|
||||
.BuildServiceProvider();
|
||||
using var output = new StringWriter();
|
||||
|
||||
var exitCode = await RestoreModeRunner.RunAsync(
|
||||
["--restore", "--backup-id=abc", "--target-connection=conn", "--target-attachments=/tmp/x"],
|
||||
services, output, CancellationToken.None);
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
exitCode = await RestoreModeRunner.RunAsync(
|
||||
["--restore", "--backup-id=abc", $"--target-connection-file={connectionFile}", "--target-attachments=/tmp/x"],
|
||||
services, output, CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(connectionFile);
|
||||
}
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("GESLAAGD", output.ToString(), StringComparison.Ordinal);
|
||||
Assert.Equal(connectionString, receivedConnectionString);
|
||||
Assert.DoesNotContain(connectionString, output.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_restore_returns_a_non_zero_exit_code_and_reports_the_mismatch()
|
||||
{
|
||||
var connectionFile = await CreateConnectionFileAsync("Host=restore-db;Username=restore;Password=public-fixture");
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IRestoreService>(new FakeRestoreService(shouldSucceed: false))
|
||||
.BuildServiceProvider();
|
||||
using var output = new StringWriter();
|
||||
|
||||
var exitCode = await RestoreModeRunner.RunAsync(
|
||||
["--restore", "--backup-id=abc", "--target-connection=conn", "--target-attachments=/tmp/x"],
|
||||
services, output, CancellationToken.None);
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
exitCode = await RestoreModeRunner.RunAsync(
|
||||
["--restore", "--backup-id=abc", $"--target-connection-file={connectionFile}", "--target-attachments=/tmp/x"],
|
||||
services, output, CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(connectionFile);
|
||||
}
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.Contains("MISLUKT", output.ToString(), StringComparison.Ordinal);
|
||||
Assert.Contains("AFWIJKING", output.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class FakeRestoreService(bool shouldSucceed) : IRestoreService
|
||||
[Fact]
|
||||
public async Task Plaintext_connection_argument_is_not_accepted_or_echoed()
|
||||
{
|
||||
const string secret = "Password=public-fixture";
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IRestoreService>(new FakeRestoreService(shouldSucceed: true))
|
||||
.BuildServiceProvider();
|
||||
using var output = new StringWriter();
|
||||
|
||||
var exitCode = await RestoreModeRunner.RunAsync(
|
||||
["--restore", "--backup-id=abc", $"--target-connection={secret}", "--target-attachments=/tmp/x"],
|
||||
services, output, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, exitCode);
|
||||
Assert.DoesNotContain(secret, output.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_failure_does_not_print_exception_details_that_may_contain_credentials()
|
||||
{
|
||||
const string secret = "Password=public-fixture";
|
||||
var connectionFile = await CreateConnectionFileAsync("Host=restore-db;" + secret);
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IRestoreService>(new FakeRestoreService(exception: new InvalidOperationException(secret)))
|
||||
.BuildServiceProvider();
|
||||
using var output = new StringWriter();
|
||||
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
exitCode = await RestoreModeRunner.RunAsync(
|
||||
["--restore", "--backup-id=abc", $"--target-connection-file={connectionFile}", "--target-attachments=/tmp/x"],
|
||||
services, output, CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(connectionFile);
|
||||
}
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.DoesNotContain(secret, output.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> CreateConnectionFileAsync(string connectionString)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "ikh-restore-connection-" + Guid.NewGuid().ToString("N"));
|
||||
await File.WriteAllTextAsync(path, connectionString);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
return path;
|
||||
}
|
||||
|
||||
private sealed class FakeRestoreService(
|
||||
bool shouldSucceed = true,
|
||||
Action<RestoreTarget>? onRestore = null,
|
||||
Exception? exception = null) : IRestoreService
|
||||
{
|
||||
public Task<RestoreReport> RestoreAsync(string backupId, RestoreTarget target, CancellationToken cancellationToken)
|
||||
{
|
||||
if (exception is not null) throw exception;
|
||||
onRestore?.Invoke(target);
|
||||
var tables = shouldSucceed
|
||||
? new List<RestoreTableReport> { new("pages", 3, 3) }
|
||||
: new List<RestoreTableReport> { new("pages", 2, 3) };
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Globalization;
|
||||
using ItWorx.KnowledgeHub.Domain.Jobs;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests;
|
||||
|
||||
public sealed class BackgroundJobTests
|
||||
{
|
||||
private static BackgroundJob NewJob(int total = 2) => BackgroundJob.Create(
|
||||
Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), BackgroundJobType.CourseTagging,
|
||||
"{}", new string('a', 64), new string('b', 64), "correlation", DateTimeOffset.UtcNow, total);
|
||||
|
||||
[Fact]
|
||||
public void A_retried_job_can_resume_and_finish_without_losing_its_original_start_time()
|
||||
{
|
||||
var job = NewJob();
|
||||
var now = DateTimeOffset.Parse("2026-10-02T12:00:00Z", CultureInfo.InvariantCulture);
|
||||
job.Start(Guid.NewGuid(), now, now.AddMinutes(1));
|
||||
job.ScheduleRetry(now.AddSeconds(1), now.AddSeconds(3), "worker_retry", "Tijdelijke fout.");
|
||||
|
||||
Assert.Equal(BackgroundJobStatus.Queued, job.Status);
|
||||
Assert.Equal(0, job.Failed);
|
||||
job.Start(Guid.NewGuid(), now.AddSeconds(3), now.AddMinutes(2));
|
||||
job.RecordOutcome(BackgroundJobItemState.Succeeded);
|
||||
job.RecordOutcome(BackgroundJobItemState.Succeeded);
|
||||
job.Complete(now.AddSeconds(4), "{\"complete\":true}");
|
||||
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, job.Status);
|
||||
Assert.Equal(now, job.StartedAt);
|
||||
Assert.Equal(2, job.Processed);
|
||||
Assert.Equal(2, job.Succeeded);
|
||||
Assert.Equal(100, job.Progress);
|
||||
Assert.Equal("{\"complete\":true}", job.ResultJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_worker_stops_after_three_retries_and_records_a_bounded_safe_failure()
|
||||
{
|
||||
var job = NewJob();
|
||||
var now = DateTimeOffset.Parse("2026-10-02T12:00:00Z", CultureInfo.InvariantCulture);
|
||||
|
||||
for (var attempt = 1; attempt <= 4; attempt++)
|
||||
{
|
||||
var attemptAt = now.AddSeconds(attempt * 10);
|
||||
job.Start(Guid.NewGuid(), attemptAt, attemptAt.AddMinutes(1));
|
||||
job.ScheduleRetry(attemptAt, attemptAt.AddSeconds(1), "processing_retry", "Veilige foutmelding.");
|
||||
if (attempt < 4)
|
||||
Assert.Equal(BackgroundJobStatus.Queued, job.Status);
|
||||
}
|
||||
|
||||
Assert.Equal(BackgroundJobStatus.Failed, job.Status);
|
||||
Assert.Equal(1, job.Failed);
|
||||
Assert.Equal(1, job.Processed);
|
||||
Assert.Equal(4, job.AttemptCount);
|
||||
Assert.True(job.ErrorsJson.Length < 4096);
|
||||
Assert.Contains("Veilige foutmelding", job.ErrorsJson, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancellation_is_immediate_for_queued_jobs_and_cooperative_for_running_jobs()
|
||||
{
|
||||
var queued = NewJob();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
queued.RequestCancellation(now);
|
||||
Assert.Equal(BackgroundJobStatus.Cancelled, queued.Status);
|
||||
Assert.True(queued.IsTerminal);
|
||||
|
||||
var running = NewJob();
|
||||
running.Start(Guid.NewGuid(), now, now.AddMinutes(1));
|
||||
running.RequestCancellation(now.AddSeconds(1));
|
||||
Assert.Equal(BackgroundJobStatus.Running, running.Status);
|
||||
Assert.True(running.CancelRequestedAt.HasValue);
|
||||
running.Cancel(now.AddSeconds(2));
|
||||
Assert.Equal(BackgroundJobStatus.Cancelled, running.Status);
|
||||
Assert.Equal(0, running.Processed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Errors_are_capped_and_result_json_is_bounded()
|
||||
{
|
||||
var job = NewJob(30);
|
||||
for (var index = 0; index < 30; index++)
|
||||
job.RecordOutcome(BackgroundJobItemState.Failed, "page_failed", new string('x', 500), Guid.NewGuid());
|
||||
job.Complete(DateTimeOffset.UtcNow, new string('z', 5000));
|
||||
|
||||
Assert.Equal(BackgroundJobStatus.Failed, job.Status);
|
||||
Assert.Null(job.ResultJson);
|
||||
Assert.Equal(BackgroundJob.MaxStoredErrors, System.Text.Json.JsonSerializer.Deserialize<BackgroundJobError[]>(job.ErrorsJson)!.Length);
|
||||
Assert.All(System.Text.Json.JsonSerializer.Deserialize<BackgroundJobError[]>(job.ErrorsJson)!, error =>
|
||||
{
|
||||
Assert.Equal(BackgroundJob.MaxErrorMessageLength, error.Message.Length);
|
||||
Assert.Equal("page_failed", error.Code);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,14 @@ public sealed class InfrastructureKnowledgeTests
|
||||
var service = InfrastructureService.Create(
|
||||
Guid.NewGuid(), "docker:knowledge-hub", "knowledge-hub", now, Guid.NewGuid());
|
||||
service.Confirm();
|
||||
service.Refresh(null, "knowledge-hub", "container", "running", HealthState.Healthy,
|
||||
"example/image", "", "", false, true, false, FactConfidence.High, Guid.NewGuid(), now);
|
||||
|
||||
service.MarkMissing(now.AddMinutes(1));
|
||||
Assert.Equal(InventoryPresence.Missing, service.Presence);
|
||||
Assert.Equal(HealthState.Unknown, service.Health);
|
||||
Assert.Equal("unknown", service.RuntimeStatus);
|
||||
Assert.Equal(now, service.LastSeenAt);
|
||||
|
||||
service.Refresh(
|
||||
null, "knowledge-hub", "container", "running", HealthState.Healthy,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Sources;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.Domain.Tests.Sources;
|
||||
|
||||
public sealed class DataSourceTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 10, 2, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void A_registered_source_is_unknown_until_it_has_observed_evidence()
|
||||
{
|
||||
var source = DataSource.Create(Guid.NewGuid(), "inventory:test", "Inventory", DataSourceKind.Inventory, 24, Now);
|
||||
Assert.Equal("Unknown", source.GetState(Now));
|
||||
Assert.Null(source.LastSuccessAt);
|
||||
Assert.Null(source.RecordCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Newer_failure_outweighs_earlier_success_and_stale_success_is_not_current()
|
||||
{
|
||||
var source = DataSource.Create(Guid.NewGuid(), "inventory:test", "Inventory", DataSourceKind.Inventory, 24, Now);
|
||||
source.Observe(true, 12, null, "run1", Now);
|
||||
Assert.Equal("Current", source.GetState(Now));
|
||||
Assert.Equal("Stale", source.GetState(Now.AddHours(25)));
|
||||
source.Observe(false, null, "connectivity", "run2", Now.AddHours(1));
|
||||
Assert.Equal("Failed", source.GetState(Now.AddHours(1)));
|
||||
Assert.Equal(12, source.RecordCount);
|
||||
Assert.Throws<ArgumentException>(() => source.Observe(true, 99, null, "older", Now.AddMinutes(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Importing_old_data_does_not_refresh_its_freshness()
|
||||
{
|
||||
var source = DataSource.Create(Guid.NewGuid(), "inventory:test", "Inventory", DataSourceKind.Inventory, 24, Now);
|
||||
source.Observe(true, 12, null, "import", Now, Now.AddDays(-2));
|
||||
Assert.Equal("Stale", source.GetState(Now));
|
||||
Assert.Equal(Now, source.LastSuccessAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabling_preserves_prior_evidence_and_observations_require_safe_error_codes()
|
||||
{
|
||||
var source = DataSource.Create(Guid.NewGuid(), "inventory:test", "Inventory", DataSourceKind.Inventory, 24, Now);
|
||||
source.Observe(true, 12, null, "run1", Now);
|
||||
source.Configure("Inventory", false, 24);
|
||||
Assert.Equal("Disabled", source.GetState(Now));
|
||||
Assert.Equal(Now, source.LastSuccessAt);
|
||||
Assert.Throws<ArgumentException>(() => source.Observe(false, null, "secret connection string", "run2", Now));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -73,7 +73,7 @@ public sealed class RoleAuthorizationTests(PostgresContainerFixture postgres) :
|
||||
await SetupHelper.LoginAsync(adminClient, OwnerEmail, OwnerPassword);
|
||||
|
||||
const string email = "tijdelijk@itworx.test";
|
||||
const string password = "temporary-strong-password";
|
||||
const string password = "public-fixture";
|
||||
var created = await CreateUserAsync(adminClient, email, password, "Editor");
|
||||
|
||||
using var targetClient = Factory.CreateClient();
|
||||
|
||||
+9
-1
@@ -23,6 +23,8 @@ public sealed class KnowledgeHubIntegrationFactory : WebApplicationFactory<Progr
|
||||
Path.Combine(Path.GetTempPath(), "ikh-tests-imports", Guid.NewGuid().ToString("N"));
|
||||
private readonly string _backupsDirectory =
|
||||
Path.Combine(Path.GetTempPath(), "ikh-tests-backups", Guid.NewGuid().ToString("N"));
|
||||
private readonly string _keysDirectory =
|
||||
Path.Combine(Path.GetTempPath(), "ikh-tests-keys", Guid.NewGuid().ToString("N"));
|
||||
|
||||
public KnowledgeHubIntegrationFactory(
|
||||
PostgresContainerFixture postgres,
|
||||
@@ -63,6 +65,11 @@ public sealed class KnowledgeHubIntegrationFactory : WebApplicationFactory<Progr
|
||||
Directory.Delete(_backupsDirectory, recursive: true);
|
||||
}
|
||||
|
||||
if (Directory.Exists(_keysDirectory))
|
||||
{
|
||||
Directory.Delete(_keysDirectory, recursive: true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(_postgres.AdminConnectionString);
|
||||
@@ -124,7 +131,8 @@ public sealed class KnowledgeHubIntegrationFactory : WebApplicationFactory<Progr
|
||||
|
||||
["KnowledgeHub:Paths:Attachments"] = _attachmentsDirectory,
|
||||
["KnowledgeHub:Paths:Imports"] = _importsDirectory,
|
||||
["KnowledgeHub:Paths:Backups"] = _backupsDirectory
|
||||
["KnowledgeHub:Paths:Backups"] = _backupsDirectory,
|
||||
["KnowledgeHub:Paths:Keys"] = _keysDirectory
|
||||
};
|
||||
|
||||
foreach (var (key, value) in _additionalConfiguration)
|
||||
|
||||
+5
-1
@@ -45,7 +45,11 @@ public sealed class PostgresContainerFixture : IAsyncLifetime
|
||||
"postgres:18.4-bookworm@sha256:882236b897e39051d2368c5ccc6cda944904723506b2dfc97f2a8f5bc9afa382")
|
||||
.WithDatabase("knowledge_hub_admin")
|
||||
.WithUsername("knowledge_hub")
|
||||
.WithPassword("integration-test-only");
|
||||
.WithPassword("integration-test-only")
|
||||
.WithLabel("tech.itworx.cleanup-allowed", "true")
|
||||
.WithLabel("tech.itworx.scope", "knowledge-hub-test")
|
||||
.WithLabel("tech.itworx.cleanup-owner", "backend-tests-" + Guid.NewGuid().ToString("N"))
|
||||
.WithLabel("tech.itworx.expires-at", DateTimeOffset.UtcNow.AddHours(6).ToString("O", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
if (UsesSharedNetwork)
|
||||
{
|
||||
|
||||
+72
@@ -2,10 +2,12 @@ using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.Contracts.Admin;
|
||||
using ItWorx.KnowledgeHub.Contracts.Automation;
|
||||
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Sources;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -16,6 +18,76 @@ namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class InfrastructureSynchronizationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Older_or_future_capture_cannot_replace_current_inventory_or_make_it_fresh()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var currentCapture = DateTimeOffset.UtcNow.AddHours(-48);
|
||||
using var current = await SetupHelper.PostWithCsrfAsync(client,
|
||||
"/api/v1/infrastructure/inventory/snapshots",
|
||||
Snapshot("monotonic-current", true, "running") with { CapturedAt = currentCapture });
|
||||
Assert.Equal(HttpStatusCode.OK, current.StatusCode);
|
||||
using var sourceResponse = await client.GetAsync("/api/v1/admin/data-sources?limit=100");
|
||||
using var sources = JsonDocument.Parse(await sourceResponse.Content.ReadAsStringAsync());
|
||||
var source = sources.RootElement.GetProperty("items").EnumerateArray()
|
||||
.Single(item => item.GetProperty("sourceKey").GetString() == "inventory:default");
|
||||
Assert.Equal("Stale", source.GetProperty("state").GetString());
|
||||
using var older = await SetupHelper.PostWithCsrfAsync(client,
|
||||
"/api/v1/infrastructure/inventory/snapshots",
|
||||
Snapshot("monotonic-older", false, "stopped") with { CapturedAt = currentCapture.AddHours(-1) });
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, older.StatusCode);
|
||||
using var future = await SetupHelper.PostWithCsrfAsync(client,
|
||||
"/api/v1/infrastructure/inventory/snapshots",
|
||||
Snapshot("monotonic-future", false, "stopped") with { CapturedAt = DateTimeOffset.UtcNow.AddHours(1) });
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, future.StatusCode);
|
||||
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true");
|
||||
Assert.Equal("running", Assert.Single(services!.Items).RuntimeStatus);
|
||||
using var replay = await SetupHelper.PostWithCsrfAsync(client,
|
||||
"/api/v1/infrastructure/inventory/snapshots",
|
||||
Snapshot("monotonic-current", true, "running") with { CapturedAt = currentCapture });
|
||||
Assert.Equal(HttpStatusCode.OK, replay.StatusCode);
|
||||
Assert.True((await replay.Content.ReadFromJsonAsync<InventorySyncResponse>())!.AlreadyImported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabling_inventory_source_prevents_further_ingestion()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
using var first = await SetupHelper.PostWithCsrfAsync(client,
|
||||
"/api/v1/infrastructure/inventory/snapshots", Snapshot("disabled-before", true, "running"));
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
using var response = await client.GetAsync("/api/v1/admin/data-sources?limit=100");
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var source = document.RootElement.GetProperty("items").EnumerateArray()
|
||||
.Single(item => item.GetProperty("sourceKey").GetString() == "inventory:default");
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var registered = await db.Set<DataSource>().SingleAsync();
|
||||
Assert.True(registered.Enabled);
|
||||
using var disabled = await SetupHelper.PutWithCsrfAsync(client,
|
||||
$"/api/v1/admin/data-sources/{source.GetProperty("id").GetGuid()}",
|
||||
new { displayName = "Infrastructuurinventaris", enabled = false, expectedIntervalHours = 24 },
|
||||
source.GetProperty("eTag").GetString());
|
||||
Assert.Equal(HttpStatusCode.OK, disabled.StatusCode);
|
||||
using var rejected = await SetupHelper.PostWithCsrfAsync(client,
|
||||
"/api/v1/infrastructure/inventory/snapshots", Snapshot("disabled-after", false, "stopped"));
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, rejected.StatusCode);
|
||||
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
|
||||
"/api/v1/infrastructure/services?includeHidden=true");
|
||||
Assert.Single(services!.Items);
|
||||
|
||||
// The transaction boundary must independently reject a disable that won
|
||||
// after the application-level check, even with an already tracked source.
|
||||
Assert.True(registered.Enabled); // stale identity-map instance from before disable
|
||||
var snapshotsBefore = await db.InventorySnapshots.CountAsync();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IInfrastructureRepository>();
|
||||
await Assert.ThrowsAsync<InactiveInventorySourceException>(() => repository.ApplySnapshotAsync(
|
||||
registered.WorkspaceId, new InventorySnapshotInput("late-import", "late-hash", DateTimeOffset.UtcNow, [], []),
|
||||
DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
Assert.Equal(snapshotsBefore, await db.InventorySnapshots.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inventory_facts_are_typed_and_editorial_knowledge_survives_later_snapshots()
|
||||
{
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Jobs;
|
||||
using ItWorx.KnowledgeHub.Application.Jobs.UseCases;
|
||||
using ItWorx.KnowledgeHub.Domain.Jobs;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Jobs;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class BackgroundJobConsistencyTests(PostgresContainerFixture postgres)
|
||||
: OwnerSetupTestBase(postgres, new Dictionary<string, string?> { ["KnowledgeHub:Jobs:Enabled"] = "false" })
|
||||
{
|
||||
[Fact]
|
||||
public async Task Queue_and_cancel_audit_failures_roll_back_job_mutations_and_retries_are_idempotent()
|
||||
{
|
||||
var (workspaceId, actorId) = await GetOwnerContextAsync();
|
||||
const string key = "audit-atomic-background-job";
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
await InstallAuditFailureTriggerAsync(db, "job.queued");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Assert.ThrowsAsync<DbUpdateException>(async () =>
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<QueueBackgroundJobUseCase>()
|
||||
.QueueBackupAsync(new QueueJobCommand(workspaceId, actorId, key, "audit-atomic"), CancellationToken.None);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
await RemoveAuditFailureTriggerAsync();
|
||||
}
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
Assert.Empty(await db.BackgroundJobs.AsNoTracking().ToListAsync());
|
||||
Assert.False(await db.AuditEvents.AsNoTracking().AnyAsync(item => item.Action == "job.queued"));
|
||||
}
|
||||
|
||||
EnqueueJobResult created;
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
created = await scope.ServiceProvider.GetRequiredService<QueueBackgroundJobUseCase>()
|
||||
.QueueBackupAsync(new QueueJobCommand(workspaceId, actorId, key, "audit-atomic"), CancellationToken.None);
|
||||
}
|
||||
Assert.Equal(EnqueueJobStatus.Created, created.Status);
|
||||
Assert.NotNull(created.Job);
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var replay = await scope.ServiceProvider.GetRequiredService<QueueBackgroundJobUseCase>()
|
||||
.QueueBackupAsync(new QueueJobCommand(workspaceId, actorId, key, "audit-atomic"), CancellationToken.None);
|
||||
Assert.Equal(EnqueueJobStatus.Existing, replay.Status);
|
||||
Assert.Equal(created.Job.Id, replay.Job?.Id);
|
||||
}
|
||||
|
||||
var queuedVersion = created.Job.Version;
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
await InstallAuditFailureTriggerAsync(db, "job.cancel_requested");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Assert.ThrowsAsync<DbUpdateException>(async () =>
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<CancelBackgroundJobUseCase>()
|
||||
.ExecuteAsync(workspaceId, created.Job.Id, actorId, "audit-atomic", CancellationToken.None);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
await RemoveAuditFailureTriggerAsync();
|
||||
}
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var persisted = await db.BackgroundJobs.AsNoTracking().SingleAsync(job => job.Id == created.Job.Id);
|
||||
Assert.Equal(BackgroundJobStatus.Queued, persisted.Status);
|
||||
Assert.Null(persisted.CancelRequestedAt);
|
||||
Assert.Equal(queuedVersion, persisted.Version);
|
||||
Assert.False(await db.AuditEvents.AsNoTracking().AnyAsync(item => item.Action == "job.cancel_requested"));
|
||||
}
|
||||
|
||||
var lease = Guid.NewGuid();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>();
|
||||
Assert.Equal(created.Job.Id, await repository.TryClaimNextAsync(
|
||||
lease, now, now.AddMinutes(2), CancellationToken.None));
|
||||
}
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var cancellation = scope.ServiceProvider.GetRequiredService<CancelBackgroundJobUseCase>();
|
||||
var first = await cancellation.ExecuteAsync(
|
||||
workspaceId, created.Job.Id, actorId, "audit-atomic", CancellationToken.None);
|
||||
var second = await cancellation.ExecuteAsync(
|
||||
workspaceId, created.Job.Id, actorId, "audit-atomic", CancellationToken.None);
|
||||
|
||||
Assert.Equal(CancelBackgroundJobStatus.Requested, first.Status);
|
||||
Assert.Equal(CancelBackgroundJobStatus.Requested, second.Status);
|
||||
Assert.NotNull(first.Job);
|
||||
Assert.NotNull(second.Job);
|
||||
Assert.Equal(first.Job.Version, second.Job.Version);
|
||||
Assert.Equal(first.Job.Version, queuedVersion + 2);
|
||||
}
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
Assert.Equal(1, await db.AuditEvents.AsNoTracking().CountAsync(item =>
|
||||
item.Action == "job.queued" && item.TargetId == created.Job.Id.ToString("D")));
|
||||
Assert.Equal(1, await db.AuditEvents.AsNoTracking().CountAsync(item =>
|
||||
item.Action == "job.cancel_requested" && item.TargetId == created.Job.Id.ToString("D")));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_rebases_its_version_when_a_concurrent_cancellation_wins_the_row_lock()
|
||||
{
|
||||
var (workspaceId, actorId) = await GetOwnerContextAsync();
|
||||
EnqueueJobResult queued;
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
queued = await scope.ServiceProvider.GetRequiredService<QueueBackgroundJobUseCase>()
|
||||
.QueueBackupAsync(new QueueJobCommand(workspaceId, actorId, "version-race", "version-race"), CancellationToken.None);
|
||||
}
|
||||
Assert.Equal(EnqueueJobStatus.Created, queued.Status);
|
||||
Assert.NotNull(queued.Job);
|
||||
|
||||
var leaseToken = Guid.NewGuid();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>();
|
||||
Assert.Equal(queued.Job.Id, await repository.TryClaimNextAsync(
|
||||
leaseToken, now, now.AddMinutes(2), CancellationToken.None));
|
||||
}
|
||||
|
||||
using var lockScope = Factory.Services.CreateScope();
|
||||
var lockDb = lockScope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
await lockDb.Database.OpenConnectionAsync();
|
||||
await using var transaction = await lockDb.Database.BeginTransactionAsync();
|
||||
var cancellationAt = now.AddSeconds(1);
|
||||
Assert.Equal(1, await lockDb.Database.ExecuteSqlInterpolatedAsync($"""
|
||||
UPDATE background_jobs
|
||||
SET cancel_requested_at = {cancellationAt}, version = version + 1
|
||||
WHERE id = {queued.Job.Id} AND status = 'Running' AND lease_token = {leaseToken}
|
||||
""", CancellationToken.None));
|
||||
var cancellationVersion = await lockDb.BackgroundJobs.AsNoTracking()
|
||||
.Where(job => job.Id == queued.Job.Id)
|
||||
.Select(job => job.Version)
|
||||
.SingleAsync();
|
||||
|
||||
using var workerScope = Factory.Services.CreateScope();
|
||||
var workerRepository = workerScope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>();
|
||||
var workerWrite = workerRepository.RecordJobOutcomeAsync(
|
||||
queued.Job.Id, leaseToken, BackgroundJobItemState.Succeeded, null, null, CancellationToken.None);
|
||||
|
||||
var updateBlockedByCancellation = false;
|
||||
var waitUntil = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
while (!updateBlockedByCancellation && DateTimeOffset.UtcNow < waitUntil)
|
||||
{
|
||||
// PostgreSQL caches statistics snapshots inside a transaction unless
|
||||
// explicitly cleared, so refresh before each activity check.
|
||||
await lockDb.Database.SqlQueryRaw<bool>("SELECT pg_stat_clear_snapshot() IS NULL AS \"Value\"")
|
||||
.SingleAsync();
|
||||
updateBlockedByCancellation = await lockDb.Database.SqlQueryRaw<bool>("""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
AND pid <> pg_backend_pid()
|
||||
AND wait_event_type = 'Lock'
|
||||
AND query ILIKE '%background_jobs%'
|
||||
) AS "Value"
|
||||
""").SingleAsync();
|
||||
if (!updateBlockedByCancellation)
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
await workerWrite.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.True(updateBlockedByCancellation, "The worker update should wait behind the concurrent cancellation row lock.");
|
||||
|
||||
using var verifyScope = Factory.Services.CreateScope();
|
||||
var current = await verifyScope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>()
|
||||
.FindAsync(workspaceId, queued.Job.Id, CancellationToken.None);
|
||||
Assert.NotNull(current);
|
||||
Assert.Equal(BackgroundJobStatus.Running, current.Status);
|
||||
Assert.Equal(leaseToken, current.LeaseToken);
|
||||
Assert.NotNull(current.CancelRequestedAt);
|
||||
Assert.InRange(Math.Abs((current.CancelRequestedAt.Value - cancellationAt).Ticks), 0, 10);
|
||||
Assert.Equal(1, current.Processed);
|
||||
Assert.Equal(1, current.Succeeded);
|
||||
Assert.True(current.Version > cancellationVersion);
|
||||
}
|
||||
|
||||
private async Task<(Guid WorkspaceId, Guid ActorId)> GetOwnerContextAsync()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var provider = scope.ServiceProvider;
|
||||
var workspaceId = await provider.GetRequiredService<IWorkspaceRepository>()
|
||||
.GetDefaultWorkspaceIdAsync(CancellationToken.None);
|
||||
var actorId = await provider.GetRequiredService<KnowledgeHubDbContext>().Users.AsNoTracking()
|
||||
.Where(user => user.Email == OwnerEmail)
|
||||
.Select(user => user.Id)
|
||||
.SingleAsync();
|
||||
return (workspaceId ?? throw new InvalidOperationException("The owner workspace is missing."), actorId);
|
||||
}
|
||||
|
||||
private static async Task InstallAuditFailureTriggerAsync(KnowledgeHubDbContext db, string action)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE test_job_audit_failure_control (action text PRIMARY KEY);
|
||||
CREATE OR REPLACE FUNCTION test_reject_job_audit() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM test_job_audit_failure_control WHERE action = NEW.action) THEN
|
||||
RAISE EXCEPTION 'intentional job audit failure';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER test_reject_job_audit
|
||||
BEFORE INSERT ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION test_reject_job_audit();
|
||||
""");
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"""
|
||||
INSERT INTO test_job_audit_failure_control(action) VALUES ({action})
|
||||
""");
|
||||
}
|
||||
|
||||
private async Task RemoveAuditFailureTriggerAsync()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>().Database.ExecuteSqlRawAsync("""
|
||||
DROP TRIGGER IF EXISTS test_reject_job_audit ON audit_events;
|
||||
DROP FUNCTION IF EXISTS test_reject_job_audit();
|
||||
DROP TABLE IF EXISTS test_job_audit_failure_control;
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Jobs;
|
||||
using ItWorx.KnowledgeHub.Contracts.Jobs;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Domain.Jobs;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Jobs;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class BackgroundJobWorkerTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Worker_tags_a_database_snapshot_in_bounded_steps_and_preserves_content_metadata_and_audit()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
using var login = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
var rootId = await CreatePageAsync(client, null, "Course root", "root markdown", []);
|
||||
var childId = await CreatePageAsync(client, rootId, "Already tagged child", "child markdown", ["course:ccna", "KeepMe"]);
|
||||
var grandchildId = await CreatePageAsync(client, rootId, "Course grandchild", "grandchild markdown", ["existing"]);
|
||||
|
||||
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/jobs/course-tagging")
|
||||
{
|
||||
Content = JsonContent.Create(new QueueCourseTaggingJobRequest(rootId, " COURSE:CCNA "))
|
||||
};
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
request.Headers.Add("Idempotency-Key", "worker-course-tagging");
|
||||
using var queued = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, queued.StatusCode);
|
||||
using var queuedJson = JsonDocument.Parse(await queued.Content.ReadAsStringAsync());
|
||||
var jobId = queuedJson.RootElement.GetProperty("id").GetGuid();
|
||||
|
||||
var terminal = await WaitForTerminalAsync(client, jobId, TimeSpan.FromSeconds(30));
|
||||
Assert.Equal("succeeded", terminal.GetProperty("status").GetString());
|
||||
Assert.Equal(3, terminal.GetProperty("total").GetInt32());
|
||||
Assert.Equal(3, terminal.GetProperty("processed").GetInt32());
|
||||
Assert.Equal(2, terminal.GetProperty("succeeded").GetInt32());
|
||||
Assert.Equal(1, terminal.GetProperty("skipped").GetInt32());
|
||||
Assert.Equal(0, terminal.GetProperty("failed").GetInt32());
|
||||
Assert.Equal(100, terminal.GetProperty("progress").GetInt32());
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<ItWorx.KnowledgeHub.Application.Knowledge.IPageRepository>();
|
||||
var root = await repository.FindAsync(rootId, CancellationToken.None);
|
||||
var child = await repository.FindAsync(childId, CancellationToken.None);
|
||||
var grandchild = await repository.FindAsync(grandchildId, CancellationToken.None);
|
||||
Assert.NotNull(root);
|
||||
Assert.NotNull(child);
|
||||
Assert.NotNull(grandchild);
|
||||
Assert.Contains(root.Tags, tag => tag == "course:ccna");
|
||||
Assert.Contains(child.Tags, tag => tag == "course:ccna");
|
||||
Assert.Contains(child.Tags, tag => tag == "KeepMe");
|
||||
Assert.Contains(grandchild.Tags, tag => tag == "course:ccna");
|
||||
Assert.Equal("root markdown", root.Markdown);
|
||||
Assert.Equal("child markdown", child.Markdown);
|
||||
Assert.Equal("grandchild markdown", grandchild.Markdown);
|
||||
Assert.Equal(2, root.CurrentRevisionNumber);
|
||||
Assert.Equal(1, child.CurrentRevisionNumber);
|
||||
Assert.Equal(2, grandchild.CurrentRevisionNumber);
|
||||
|
||||
var changedIds = new[] { rootId.ToString("D"), grandchildId.ToString("D") };
|
||||
Assert.Equal(2, await db.AuditEvents.AsNoTracking()
|
||||
.CountAsync(audit => audit.Action == "page.published" && changedIds.Contains(audit.TargetId)));
|
||||
Assert.Equal(2, await db.OutboxMessages.AsNoTracking()
|
||||
.CountAsync(message => message.EventType == "page.updated.v1" && changedIds.Contains(message.AggregateId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backup_requests_run_through_the_shared_backup_use_case_and_return_no_private_path()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
using var login = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/jobs/backups")
|
||||
{
|
||||
Content = JsonContent.Create(new QueueBackupJobRequest())
|
||||
};
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
request.Headers.Add("Idempotency-Key", "worker-backup-job");
|
||||
using var queued = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, queued.StatusCode);
|
||||
using var queuedJson = JsonDocument.Parse(await queued.Content.ReadAsStringAsync());
|
||||
var jobId = queuedJson.RootElement.GetProperty("id").GetGuid();
|
||||
|
||||
var terminal = await WaitForTerminalAsync(client, jobId, TimeSpan.FromSeconds(45));
|
||||
Assert.Equal("succeeded", terminal.GetProperty("status").GetString());
|
||||
Assert.Equal(1, terminal.GetProperty("total").GetInt32());
|
||||
Assert.Equal(1, terminal.GetProperty("succeeded").GetInt32());
|
||||
var result = terminal.GetProperty("result");
|
||||
Assert.False(result.TryGetProperty("path", out _));
|
||||
Assert.False(result.TryGetProperty("directory", out _));
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.GetProperty("backupId").GetString()));
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var audit = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>().AuditEvents;
|
||||
Assert.True(await audit.AsNoTracking().AnyAsync(item => item.Action == "backup.created"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_persists_only_a_safe_bounded_error_when_backup_storage_fails_with_a_pathful_exception()
|
||||
{
|
||||
var backupPath = Factory.BackupsDirectory;
|
||||
Assert.False(Directory.Exists(backupPath));
|
||||
Assert.False(File.Exists(backupPath));
|
||||
const string privatePathMarker = "private-backup-storage-marker";
|
||||
File.WriteAllText(backupPath, privatePathMarker);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
using var login = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
|
||||
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/jobs/backups")
|
||||
{
|
||||
Content = JsonContent.Create(new QueueBackupJobRequest())
|
||||
};
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
request.Headers.Add("Idempotency-Key", "backup-safe-error");
|
||||
using var queued = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, queued.StatusCode);
|
||||
using var queuedJson = JsonDocument.Parse(await queued.Content.ReadAsStringAsync());
|
||||
var jobId = queuedJson.RootElement.GetProperty("id").GetGuid();
|
||||
|
||||
var failedAttempt = await WaitForErrorsAsync(client, jobId, TimeSpan.FromSeconds(20));
|
||||
var errors = failedAttempt.GetProperty("errors");
|
||||
Assert.NotEqual(0, errors.GetArrayLength());
|
||||
Assert.Equal("processing_retry", errors[0].GetProperty("code").GetString());
|
||||
var safeMessage = errors[0].GetProperty("message").GetString();
|
||||
Assert.Equal("De tijdelijke verwerking is mislukt; de taak wordt opnieuw geprobeerd.", safeMessage);
|
||||
Assert.DoesNotContain(backupPath, safeMessage, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(privatePathMarker, safeMessage, StringComparison.Ordinal);
|
||||
Assert.True(safeMessage!.Length <= BackgroundJob.MaxErrorMessageLength);
|
||||
|
||||
using var cancel = await SetupHelper.PostWithCsrfAsync(
|
||||
client, $"/api/v1/jobs/{jobId:D}/cancel", new { });
|
||||
Assert.Equal(HttpStatusCode.OK, cancel.StatusCode);
|
||||
var terminal = await WaitForTerminalAsync(client, jobId, TimeSpan.FromSeconds(30));
|
||||
var terminalStatus = terminal.GetProperty("status").GetString();
|
||||
Assert.True(terminalStatus is "cancelled" or "failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(backupPath))
|
||||
File.Delete(backupPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Guid> CreatePageAsync(
|
||||
HttpClient client, Guid? parentId, string title, string markdown, IReadOnlyList<string> tags)
|
||||
{
|
||||
using var response = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/pages", new CreatePageRequest(parentId, title, "WikiPage", markdown, null, tags));
|
||||
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return json.RootElement.GetProperty("id").GetGuid();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> WaitForTerminalAsync(HttpClient client, Guid jobId, TimeSpan timeout)
|
||||
{
|
||||
var stopAt = DateTimeOffset.UtcNow + timeout;
|
||||
while (DateTimeOffset.UtcNow < stopAt)
|
||||
{
|
||||
using var response = await client.GetAsync($"/api/v1/jobs/{jobId:D}");
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var job = json.RootElement.Clone();
|
||||
if (job.GetProperty("status").GetString() is "succeeded" or "partially_succeeded" or "failed" or "cancelled")
|
||||
return job;
|
||||
}
|
||||
await Task.Delay(100);
|
||||
}
|
||||
throw new TimeoutException($"Job {jobId:D} did not reach a terminal state within {timeout}.");
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> WaitForErrorsAsync(HttpClient client, Guid jobId, TimeSpan timeout)
|
||||
{
|
||||
var stopAt = DateTimeOffset.UtcNow + timeout;
|
||||
while (DateTimeOffset.UtcNow < stopAt)
|
||||
{
|
||||
using var response = await client.GetAsync($"/api/v1/jobs/{jobId:D}");
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var job = json.RootElement.Clone();
|
||||
if (job.GetProperty("errors").GetArrayLength() > 0)
|
||||
return job;
|
||||
}
|
||||
await Task.Delay(100);
|
||||
}
|
||||
throw new TimeoutException($"Job {jobId:D} did not persist an error within {timeout}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Application.Jobs;
|
||||
using ItWorx.KnowledgeHub.Application.Jobs.UseCases;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Application.Knowledge.UseCases;
|
||||
using ItWorx.KnowledgeHub.Contracts.Admin;
|
||||
using ItWorx.KnowledgeHub.Contracts.Jobs;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Domain.Jobs;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Jobs;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class BackgroundJobsApiTests(PostgresContainerFixture postgres)
|
||||
: OwnerSetupTestBase(postgres, new Dictionary<string, string?> { ["KnowledgeHub:Jobs:Enabled"] = "false" })
|
||||
{
|
||||
[Fact]
|
||||
public async Task Completed_jobs_still_count_toward_the_daily_work_budget()
|
||||
{
|
||||
using var owner = await AuthenticatedClientAsync();
|
||||
Factory.Services.GetRequiredService<BackgroundJobsOptions>().MaxCreatedPerDay = 1;
|
||||
using var first = await QueueBackupAsync(owner, "daily-first");
|
||||
Assert.Equal(HttpStatusCode.Accepted, first.StatusCode);
|
||||
using var cancelled = await CancelAsync(owner, await ReadIdAsync(first));
|
||||
Assert.Equal(HttpStatusCode.OK, cancelled.StatusCode);
|
||||
using var excess = await QueueBackupAsync(owner, "daily-excess");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, excess.StatusCode);
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
Assert.Equal(1, await scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>().BackgroundJobs.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_distinct_jobs_cannot_exceed_the_durable_queue_budget()
|
||||
{
|
||||
using var owner = await AuthenticatedClientAsync();
|
||||
var rootId = await CreatePageAsync(owner, "Bounded job queue");
|
||||
var responses = await Task.WhenAll(Enumerable.Range(0, 6)
|
||||
.Select(index => QueueCourseAsync(owner, rootId, "course:bounded", $"bounded-{index}")));
|
||||
try
|
||||
{
|
||||
Assert.Equal(3, responses.Count(response => response.StatusCode == HttpStatusCode.Accepted));
|
||||
Assert.Equal(3, responses.Count(response => response.StatusCode == HttpStatusCode.TooManyRequests));
|
||||
var acceptedIndex = Array.FindIndex(responses, response => response.StatusCode == HttpStatusCode.Accepted);
|
||||
var jobId = await ReadIdAsync(responses[acceptedIndex]);
|
||||
using var replay = await QueueCourseAsync(owner, rootId, "course:bounded", $"bounded-{acceptedIndex}");
|
||||
Assert.Equal(HttpStatusCode.Accepted, replay.StatusCode);
|
||||
Assert.Equal(jobId, await ReadIdAsync(replay));
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
Assert.Equal(3, await db.BackgroundJobs.CountAsync());
|
||||
Assert.Equal(3, await db.BackgroundJobItems.CountAsync());
|
||||
using var cancelled = await CancelAsync(owner, jobId);
|
||||
Assert.Equal(HttpStatusCode.OK, cancelled.StatusCode);
|
||||
Assert.False(await db.BackgroundJobItems.AnyAsync(item => item.JobId == jobId));
|
||||
Assert.True(await db.BackgroundJobs.AnyAsync(job => job.Id == jobId && job.Status == BackgroundJobStatus.Cancelled));
|
||||
using var next = await QueueCourseAsync(owner, rootId, "course:bounded", "bounded-after-cancel");
|
||||
Assert.Equal(HttpStatusCode.Accepted, next.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var response in responses) response.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stale_tracked_worker_cannot_overwrite_a_reclaimed_lease()
|
||||
{
|
||||
using var owner = await AuthenticatedClientAsync();
|
||||
using var queued = await QueueBackupAsync(owner, "stale-lease-regression");
|
||||
Assert.Equal(HttpStatusCode.Accepted, queued.StatusCode);
|
||||
var jobId = await ReadIdAsync(queued);
|
||||
using var workspaceScope = Factory.Services.CreateScope();
|
||||
var workspaceId = await workspaceScope.ServiceProvider
|
||||
.GetRequiredService<IWorkspaceRepository>().GetDefaultWorkspaceIdAsync(CancellationToken.None);
|
||||
Assert.NotNull(workspaceId);
|
||||
|
||||
var firstStart = DateTimeOffset.UtcNow;
|
||||
var firstLease = Guid.NewGuid();
|
||||
using var staleScope = Factory.Services.CreateScope();
|
||||
var staleDb = staleScope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var staleRepository = staleScope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>();
|
||||
Assert.Equal(jobId, await staleRepository.TryClaimNextAsync(
|
||||
firstLease, firstStart, firstStart.AddSeconds(30), CancellationToken.None));
|
||||
var staleJob = await staleRepository.FindLeasedAsync(jobId, firstLease, CancellationToken.None);
|
||||
Assert.NotNull(staleJob);
|
||||
Assert.Equal(firstLease, staleJob.LeaseToken);
|
||||
Assert.Equal(1, staleJob.AttemptCount);
|
||||
|
||||
var secondStart = firstStart.AddSeconds(31);
|
||||
var secondLease = Guid.NewGuid();
|
||||
using var reclaimedScope = Factory.Services.CreateScope();
|
||||
var reclaimedRepository = reclaimedScope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>();
|
||||
Assert.Equal(jobId, await reclaimedRepository.TryClaimNextAsync(
|
||||
secondLease, secondStart, secondStart.AddMinutes(2), CancellationToken.None));
|
||||
var reclaimedJob = await reclaimedRepository.FindLeasedAsync(jobId, secondLease, CancellationToken.None);
|
||||
Assert.NotNull(reclaimedJob);
|
||||
Assert.Equal(2, reclaimedJob.AttemptCount);
|
||||
|
||||
staleJob.Complete(secondStart.AddSeconds(1), "{\"stale\":true}");
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => staleDb.SaveChangesAsync(CancellationToken.None));
|
||||
|
||||
using var verifyScope = Factory.Services.CreateScope();
|
||||
var current = await verifyScope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>()
|
||||
.FindAsync(workspaceId.Value, jobId, CancellationToken.None);
|
||||
Assert.NotNull(current);
|
||||
Assert.Equal(BackgroundJobStatus.Running, current.Status);
|
||||
Assert.Equal(secondLease, current.LeaseToken);
|
||||
Assert.Equal(2, current.AttemptCount);
|
||||
Assert.Null(current.CompletedAt);
|
||||
Assert.Null(current.ResultJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queue_is_idempotent_actor_scoped_and_rejects_payload_conflicts()
|
||||
{
|
||||
using var owner = await AuthenticatedClientAsync();
|
||||
var rootId = await CreatePageAsync(owner, "Job idempotency root", markdown: "unchanged body");
|
||||
const string key = "course-tag-repeat";
|
||||
|
||||
using var first = await QueueCourseAsync(owner, rootId, " course:ccna ", key);
|
||||
using var replay = await QueueCourseAsync(owner, rootId, "course:CCNA", key);
|
||||
using var conflict = await QueueCourseAsync(owner, rootId, "course:other", key);
|
||||
Assert.Equal(HttpStatusCode.Accepted, first.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Accepted, replay.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
|
||||
var firstId = await ReadIdAsync(first);
|
||||
Assert.Equal(firstId, await ReadIdAsync(replay));
|
||||
|
||||
var created = await SetupHelper.PostWithCsrfAsync(
|
||||
owner,
|
||||
"/api/v1/admin/users",
|
||||
new CreateUserRequest("Tweede beheerder", "tweede-beheerder@itworx.test", "correct-horse-battery-staple2", "Administrator"));
|
||||
Assert.True(created.IsSuccessStatusCode, await created.Content.ReadAsStringAsync());
|
||||
using var secondAdmin = Factory.CreateClient();
|
||||
using var login = await SetupHelper.LoginAsync(secondAdmin, "tweede-beheerder@itworx.test", "correct-horse-battery-staple2");
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
using var actorScoped = await QueueCourseAsync(secondAdmin, rootId, "course:ccna", key);
|
||||
Assert.Equal(HttpStatusCode.Accepted, actorScoped.StatusCode);
|
||||
Assert.NotEqual(firstId, await ReadIdAsync(actorScoped));
|
||||
|
||||
using var cancelFirst = await CancelAsync(owner, firstId);
|
||||
using var cancelSecond = await CancelAsync(secondAdmin, await ReadIdAsync(actorScoped));
|
||||
Assert.Equal(HttpStatusCode.OK, cancelFirst.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, cancelSecond.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Job_mutations_require_csrf_even_when_an_authorization_header_is_present()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var rootId = await CreatePageAsync(client, "Job CSRF root");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/jobs/course-tagging")
|
||||
{
|
||||
Content = JsonContent.Create(new QueueCourseTaggingJobRequest(rootId, "course:csrf"))
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-a-service-token");
|
||||
request.Headers.Add("Idempotency-Key", "csrf-missing");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("csrf-validation-failed", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Job_endpoints_reject_anonymous_and_non_administrator_users()
|
||||
{
|
||||
using var anonymous = Factory.CreateClient();
|
||||
using var anonymousResponse = await anonymous.GetAsync("/api/v1/jobs");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymousResponse.StatusCode);
|
||||
|
||||
using var owner = await AuthenticatedClientAsync();
|
||||
using var created = await SetupHelper.PostWithCsrfAsync(
|
||||
owner,
|
||||
"/api/v1/admin/users",
|
||||
new CreateUserRequest("Lezer", "job-lezer@itworx.test", "job-reader-password-strong", "Reader"));
|
||||
Assert.Equal(HttpStatusCode.OK, created.StatusCode);
|
||||
|
||||
using var reader = Factory.CreateClient();
|
||||
using var login = await SetupHelper.LoginAsync(reader, "job-lezer@itworx.test", "job-reader-password-strong");
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
|
||||
using var response = await reader.GetAsync("/api/v1/jobs");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queued_jobs_can_be_cancelled_and_terminal_events_close_with_the_latest_snapshot()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
using var queued = await QueueBackupAsync(client, "cancel-backup");
|
||||
Assert.Equal(HttpStatusCode.Accepted, queued.StatusCode);
|
||||
var id = await ReadIdAsync(queued);
|
||||
|
||||
using var canceled = await CancelAsync(client, id);
|
||||
Assert.Equal(HttpStatusCode.OK, canceled.StatusCode);
|
||||
using var response = JsonDocument.Parse(await canceled.Content.ReadAsStringAsync());
|
||||
Assert.Equal("cancelled", response.RootElement.GetProperty("status").GetString());
|
||||
Assert.True(response.RootElement.GetProperty("cancelRequested").GetBoolean());
|
||||
|
||||
using var events = await client.GetAsync($"/api/v1/jobs/{id}/events");
|
||||
Assert.Equal("text/event-stream", events.Content.Headers.ContentType?.MediaType);
|
||||
var body = await events.Content.ReadAsStringAsync();
|
||||
Assert.Contains("event: terminal", body, StringComparison.Ordinal);
|
||||
Assert.Contains("cancelled", body, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sse_emits_terminal_and_closes_even_when_terminal_version_matches_the_last_progress_event()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
using var queued = await QueueBackupAsync(client, "same-version-terminal-event");
|
||||
Assert.Equal(HttpStatusCode.Accepted, queued.StatusCode);
|
||||
var jobId = await ReadIdAsync(queued);
|
||||
var workspaceId = await Factory.Services.GetRequiredService<IWorkspaceRepository>()
|
||||
.GetDefaultWorkspaceIdAsync(CancellationToken.None);
|
||||
Assert.NotNull(workspaceId);
|
||||
var initial = await Factory.Services.GetRequiredService<IBackgroundJobRepository>()
|
||||
.FindAsync(workspaceId.Value, jobId, CancellationToken.None);
|
||||
Assert.NotNull(initial);
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var response = await client.GetAsync(
|
||||
$"/api/v1/jobs/{jobId:D}/events", HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token);
|
||||
using var reader = new StreamReader(stream);
|
||||
var sawInitialJob = false;
|
||||
while (await reader.ReadLineAsync(timeout.Token) is { } line)
|
||||
{
|
||||
if (line == "event: job")
|
||||
sawInitialJob = true;
|
||||
if (line.Length == 0 && sawInitialJob)
|
||||
break;
|
||||
}
|
||||
Assert.True(sawInitialJob);
|
||||
|
||||
using (var scope = Factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
Assert.Equal(1, await db.Database.ExecuteSqlInterpolatedAsync($"""
|
||||
UPDATE background_jobs
|
||||
SET status = 'Cancelled', completed_at = {DateTimeOffset.UtcNow}
|
||||
WHERE id = {jobId}
|
||||
"""));
|
||||
var terminal = await db.BackgroundJobs.AsNoTracking().SingleAsync(job => job.Id == jobId);
|
||||
Assert.Equal(initial.Version, terminal.Version);
|
||||
}
|
||||
|
||||
var remaining = await reader.ReadToEndAsync(timeout.Token);
|
||||
Assert.Contains("event: terminal", remaining, StringComparison.Ordinal);
|
||||
Assert.Contains("cancelled", remaining, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Running_job_cancellation_is_persisted_until_the_current_lease_holder_stops()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
using var queued = await QueueBackupAsync(client, "cancel-running-backup");
|
||||
var jobId = await ReadIdAsync(queued);
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>();
|
||||
var workspaceId = await scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>()
|
||||
.GetDefaultWorkspaceIdAsync(CancellationToken.None);
|
||||
Assert.NotNull(workspaceId);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var lease = Guid.NewGuid();
|
||||
Assert.Equal(jobId, await repository.TryClaimNextAsync(lease, now, now.AddMinutes(2), CancellationToken.None));
|
||||
|
||||
using var cancellation = await CancelAsync(client, jobId);
|
||||
Assert.Equal(HttpStatusCode.OK, cancellation.StatusCode);
|
||||
using var cancellationJson = JsonDocument.Parse(await cancellation.Content.ReadAsStringAsync());
|
||||
Assert.Equal("running", cancellationJson.RootElement.GetProperty("status").GetString());
|
||||
Assert.True(cancellationJson.RootElement.GetProperty("cancelRequested").GetBoolean());
|
||||
Assert.True(await repository.IsCancellationRequestedAsync(jobId, lease, CancellationToken.None));
|
||||
|
||||
await repository.CancelLeasedJobAsync(jobId, lease, now.AddSeconds(1), CancellationToken.None);
|
||||
var cancelled = await repository.FindAsync(workspaceId.Value, jobId, CancellationToken.None);
|
||||
Assert.NotNull(cancelled);
|
||||
Assert.Equal(BackgroundJobStatus.Cancelled, cancelled.Status);
|
||||
Assert.Null(cancelled.LeaseToken);
|
||||
Assert.Null(cancelled.LeaseUntil);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Job_listing_is_bounded_and_scoped_to_the_workspace()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
using var queued = await QueueBackupAsync(client, "list-backup");
|
||||
var id = await ReadIdAsync(queued);
|
||||
using var response = await client.GetAsync("/api/v1/jobs?limit=1");
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var items = json.RootElement.GetProperty("items");
|
||||
Assert.Equal(1, items.GetArrayLength());
|
||||
Assert.Equal(id, items[0].GetProperty("id").GetGuid());
|
||||
Assert.Equal("backup", items[0].GetProperty("type").GetString());
|
||||
Assert.Equal("queued", items[0].GetProperty("status").GetString());
|
||||
Assert.Equal(0, items[0].GetProperty("progress").GetInt32());
|
||||
Assert.Equal(1, items[0].GetProperty("total").GetInt32());
|
||||
Assert.Equal(0, items[0].GetProperty("processed").GetInt32());
|
||||
Assert.Empty(items[0].GetProperty("errors").EnumerateArray());
|
||||
|
||||
using var cancel = await CancelAsync(client, id);
|
||||
Assert.Equal(HttpStatusCode.OK, cancel.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_workers_claim_distinct_queued_jobs()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var first = await QueueBackupAsync(client, "claim-one");
|
||||
var second = await QueueBackupAsync(client, "claim-two");
|
||||
var firstId = await ReadIdAsync(first);
|
||||
var secondId = await ReadIdAsync(second);
|
||||
first.Dispose();
|
||||
second.Dispose();
|
||||
|
||||
using var firstScope = Factory.Services.CreateScope();
|
||||
using var secondScope = Factory.Services.CreateScope();
|
||||
var firstRepository = firstScope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>();
|
||||
var secondRepository = secondScope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var claims = await Task.WhenAll(
|
||||
firstRepository.TryClaimNextAsync(Guid.NewGuid(), now, now.AddMinutes(2), CancellationToken.None),
|
||||
secondRepository.TryClaimNextAsync(Guid.NewGuid(), now, now.AddMinutes(2), CancellationToken.None));
|
||||
|
||||
var claimedIds = claims.Select(claim => claim ?? throw new Xunit.Sdk.XunitException("A queued job was not claimed.")).ToArray();
|
||||
Assert.Equal(2, claimedIds.Distinct().Count());
|
||||
Assert.Contains(firstId, claimedIds);
|
||||
Assert.Contains(secondId, claimedIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Postgres_recovers_an_expired_lease_rejects_the_stale_worker_and_bounds_retry_failures()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
using var queued = await QueueBackupAsync(client, "retry-recovery-backup");
|
||||
Assert.Equal(HttpStatusCode.Accepted, queued.StatusCode);
|
||||
var jobId = await ReadIdAsync(queued);
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>();
|
||||
var workspaceId = await scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>()
|
||||
.GetDefaultWorkspaceIdAsync(CancellationToken.None);
|
||||
Assert.NotNull(workspaceId);
|
||||
|
||||
const string safeMessage = "De tijdelijke verwerking is mislukt; de taak wordt opnieuw geprobeerd.";
|
||||
var firstStart = DateTimeOffset.UtcNow;
|
||||
var firstLease = Guid.NewGuid();
|
||||
Assert.Equal(jobId, await repository.TryClaimNextAsync(
|
||||
firstLease, firstStart, firstStart.AddSeconds(30), CancellationToken.None));
|
||||
await repository.RetryLeasedJobAsync(
|
||||
jobId, firstLease, firstStart, firstStart.AddSeconds(1),
|
||||
"processing_retry", safeMessage, CancellationToken.None);
|
||||
Assert.False(await repository.RenewLeaseAsync(
|
||||
jobId, firstLease, firstStart.AddMinutes(1), CancellationToken.None));
|
||||
|
||||
var secondStart = firstStart.AddSeconds(1);
|
||||
var secondLease = Guid.NewGuid();
|
||||
Assert.Equal(jobId, await repository.TryClaimNextAsync(
|
||||
secondLease, secondStart, secondStart.AddSeconds(30), CancellationToken.None));
|
||||
|
||||
// Simulate a process stop with the second lease still running. PostgreSQL must
|
||||
// make the same durable job claimable again after its lease expires.
|
||||
var thirdStart = secondStart.AddSeconds(31);
|
||||
var thirdLease = Guid.NewGuid();
|
||||
Assert.Equal(jobId, await repository.TryClaimNextAsync(
|
||||
thirdLease, thirdStart, thirdStart.AddSeconds(30), CancellationToken.None));
|
||||
var resumed = await repository.FindLeasedAsync(jobId, thirdLease, CancellationToken.None);
|
||||
Assert.NotNull(resumed);
|
||||
Assert.Equal(3, resumed.AttemptCount);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => repository.CompleteLeasedJobAsync(
|
||||
jobId, secondLease, "{}", thirdStart, CancellationToken.None));
|
||||
|
||||
await repository.RetryLeasedJobAsync(
|
||||
jobId, thirdLease, thirdStart, thirdStart.AddSeconds(1),
|
||||
"processing_retry", safeMessage, CancellationToken.None);
|
||||
var fourthStart = thirdStart.AddSeconds(1);
|
||||
var fourthLease = Guid.NewGuid();
|
||||
Assert.Equal(jobId, await repository.TryClaimNextAsync(
|
||||
fourthLease, fourthStart, fourthStart.AddSeconds(30), CancellationToken.None));
|
||||
var finalAttempt = await repository.FindLeasedAsync(jobId, fourthLease, CancellationToken.None);
|
||||
Assert.NotNull(finalAttempt);
|
||||
Assert.Equal(4, finalAttempt.AttemptCount);
|
||||
|
||||
await repository.RetryLeasedJobAsync(
|
||||
jobId, fourthLease, fourthStart, fourthStart.AddSeconds(1),
|
||||
"processing_retry", safeMessage, CancellationToken.None);
|
||||
var completed = await repository.FindAsync(workspaceId.Value, jobId, CancellationToken.None);
|
||||
Assert.NotNull(completed);
|
||||
Assert.Equal(BackgroundJobStatus.Failed, completed.Status);
|
||||
Assert.Equal(1, completed.Processed);
|
||||
Assert.Equal(1, completed.Failed);
|
||||
|
||||
var errors = JsonSerializer.Deserialize<BackgroundJobError[]>(completed.ErrorsJson);
|
||||
Assert.NotNull(errors);
|
||||
Assert.Equal(3, errors.Length);
|
||||
Assert.All(errors, error =>
|
||||
{
|
||||
Assert.Equal("processing_retry", error.Code);
|
||||
Assert.Equal(safeMessage, error.Message);
|
||||
Assert.True(error.Message.Length <= BackgroundJob.MaxErrorMessageLength);
|
||||
});
|
||||
Assert.True(completed.ErrorsJson.Length < 4096);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_crash_after_a_page_update_resumes_the_processing_item_without_a_duplicate_revision()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var pageId = await CreatePageAsync(client, "Job resume page", markdown: "do not change", tags: ["existing"]);
|
||||
using var response = await QueueCourseAsync(client, pageId, "course:resume", "resume-course-tag");
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
var jobId = await ReadIdAsync(response);
|
||||
|
||||
using var firstScope = Factory.Services.CreateScope();
|
||||
var repository = firstScope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>();
|
||||
var pageRepository = firstScope.ServiceProvider.GetRequiredService<IPageRepository>();
|
||||
var updatePage = firstScope.ServiceProvider.GetRequiredService<UpdatePageUseCase>();
|
||||
var actorId = await OwnerIdAsync(firstScope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>());
|
||||
var start = DateTimeOffset.UtcNow;
|
||||
var crashedLease = Guid.NewGuid();
|
||||
Assert.Equal(jobId, await repository.TryClaimNextAsync(crashedLease, start, start.AddSeconds(30), CancellationToken.None));
|
||||
var workItem = await repository.FindNextPageItemAsync(jobId, crashedLease, CancellationToken.None);
|
||||
Assert.NotNull(workItem);
|
||||
await repository.MarkPageItemProcessingAsync(jobId, crashedLease, workItem.Ordinal, start, CancellationToken.None);
|
||||
|
||||
var page = await pageRepository.FindAsync(pageId, CancellationToken.None);
|
||||
var etag = await pageRepository.GetETagAsync(pageId, CancellationToken.None);
|
||||
Assert.NotNull(page);
|
||||
Assert.NotNull(etag);
|
||||
var update = await updatePage.ExecuteAsync(new UpdatePageCommand(
|
||||
pageId, page.TitleValue, page.Markdown, page.DocumentType.ToString(), page.DocumentStatus.ToString(),
|
||||
"Cursustag course:resume toegevoegd", page.Tags.Append("course:resume").ToArray(), etag,
|
||||
actorId, "crash-resume-test"), CancellationToken.None);
|
||||
Assert.Equal(PageOperationStatus.Success, update.Status);
|
||||
// Simulate a process crash here: the published page tag is durable while the job
|
||||
// item is still Processing and its lease is allowed to expire.
|
||||
|
||||
using var resumedScope = Factory.Services.CreateScope();
|
||||
var resumedRepository = resumedScope.ServiceProvider.GetRequiredService<IBackgroundJobRepository>();
|
||||
var resumedLease = Guid.NewGuid();
|
||||
var resumedAt = start.AddSeconds(31);
|
||||
Assert.Equal(jobId, await resumedRepository.TryClaimNextAsync(resumedLease, resumedAt, resumedAt.AddMinutes(2), CancellationToken.None));
|
||||
var resumedItem = await resumedRepository.FindNextPageItemAsync(jobId, resumedLease, CancellationToken.None);
|
||||
Assert.NotNull(resumedItem);
|
||||
Assert.Equal(BackgroundJobItemState.Processing, resumedItem.State);
|
||||
Assert.False(resumedItem.InitiallyTagged);
|
||||
|
||||
var currentPage = await resumedScope.ServiceProvider.GetRequiredService<IPageRepository>()
|
||||
.FindAsync(pageId, CancellationToken.None);
|
||||
Assert.NotNull(currentPage);
|
||||
Assert.Contains(currentPage.Tags, tag => string.Equals(tag, "course:resume", StringComparison.OrdinalIgnoreCase));
|
||||
await resumedRepository.RecordPageItemOutcomeAsync(
|
||||
jobId, resumedLease, resumedItem.Ordinal, BackgroundJobItemState.Succeeded,
|
||||
null, null, null, resumedAt, CancellationToken.None);
|
||||
await resumedRepository.CompleteLeasedJobAsync(jobId, resumedLease, "{}", resumedAt, CancellationToken.None);
|
||||
var completed = await resumedRepository.FindAsync(
|
||||
(await resumedRepository.FindByIdempotencyAsync(
|
||||
(await resumedScope.ServiceProvider.GetRequiredService<IWorkspaceRepository>().GetDefaultWorkspaceIdAsync(CancellationToken.None))!.Value,
|
||||
actorId,
|
||||
await KeyHashAsync(), CancellationToken.None))!.WorkspaceId,
|
||||
jobId,
|
||||
CancellationToken.None);
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, completed!.Status);
|
||||
var revisions = await resumedScope.ServiceProvider.GetRequiredService<IPageRepository>()
|
||||
.GetRevisionsAsync(pageId, CancellationToken.None);
|
||||
Assert.Equal(2, revisions.Count);
|
||||
Assert.Equal("do not change", currentPage.Markdown);
|
||||
|
||||
static Task<string> KeyHashAsync() => Task.FromResult(Convert.ToHexStringLower(
|
||||
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("resume-course-tag"))));
|
||||
}
|
||||
|
||||
private async Task<HttpClient> AuthenticatedClientAsync()
|
||||
{
|
||||
var client = Factory.CreateClient();
|
||||
var login = await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<Guid> CreatePageAsync(HttpClient client, string title, string markdown = "page body", IReadOnlyList<string>? tags = null)
|
||||
{
|
||||
using var response = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/pages", new CreatePageRequest(null, title, "WikiPage", markdown, null, tags));
|
||||
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return json.RootElement.GetProperty("id").GetGuid();
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> QueueCourseAsync(HttpClient client, Guid rootPageId, string tag, string key)
|
||||
{
|
||||
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/jobs/course-tagging")
|
||||
{
|
||||
Content = JsonContent.Create(new QueueCourseTaggingJobRequest(rootPageId, tag))
|
||||
};
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
request.Headers.Add("Idempotency-Key", key);
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> QueueBackupAsync(HttpClient client, string key)
|
||||
{
|
||||
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/jobs/backups")
|
||||
{
|
||||
Content = JsonContent.Create(new QueueBackupJobRequest())
|
||||
};
|
||||
request.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
request.Headers.Add("Idempotency-Key", key);
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> CancelAsync(HttpClient client, Guid jobId) =>
|
||||
await SetupHelper.PostWithCsrfAsync(client, $"/api/v1/jobs/{jobId:D}/cancel", new { });
|
||||
|
||||
private static async Task<Guid> ReadIdAsync(HttpResponseMessage response)
|
||||
{
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return json.RootElement.GetProperty("id").GetGuid();
|
||||
}
|
||||
|
||||
private static async Task<Guid> OwnerIdAsync(KnowledgeHubDbContext db) =>
|
||||
await db.Users.AsNoTracking().Where(user => user.Email == "eigenaar@itworx.test")
|
||||
.Select(user => user.Id).SingleAsync();
|
||||
}
|
||||
@@ -123,6 +123,7 @@ public sealed class SearchFullScaleTests(PostgresContainerFixture postgres) : Ow
|
||||
// --- latency: p50/p95/p99 across representative query shapes ---
|
||||
report.AppendLine();
|
||||
report.AppendLine("Latency (ms), 30 samples per query shape:");
|
||||
report.AppendLine("Search samples are paced at 1.1 seconds outside the stopwatch to respect the 60/minute HTTP budget.");
|
||||
var measurements = new[]
|
||||
{
|
||||
await MeasureAsync(client, report, "common term (\"documentatie\")", "/api/v1/search?q=documentatie"),
|
||||
@@ -294,6 +295,11 @@ public sealed class SearchFullScaleTests(PostgresContainerFixture postgres) : Ow
|
||||
var samples = new List<double>(SampleCount);
|
||||
for (var i = 0; i < SampleCount; i++)
|
||||
{
|
||||
// This is a latency benchmark, not an abuse-limit benchmark. Keep the
|
||||
// real production limiter enabled and pace searches below its 60/min
|
||||
// budget; waiting is deliberately outside the unchanged latency bound.
|
||||
if (path.StartsWith("/api/v1/search?", StringComparison.Ordinal))
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(1100));
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var response = await client.GetAsync(path);
|
||||
stopwatch.Stop();
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Audit;
|
||||
using ItWorx.KnowledgeHub.Contracts.Activity;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class ActivityFeedTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Activity_excludes_security_events_before_pagination_and_audit_retains_them()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
(await SetupHelper.PostWithCsrfAsync(client, "/api/v1/pages",
|
||||
new CreatePageRequest(null, "Operationeel resultaat", "WikiPage", "Inhoud.", null))).EnsureSuccessStatusCode();
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var audit = scope.ServiceProvider.GetRequiredService<IAuditEventRecorder>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
await audit.RecordAsync(new AuditEventRecord(AuditActorKind.System, null, "security",
|
||||
"token.rotated", "service-account", Guid.NewGuid().ToString(), "Beveiligingswijziging", null,
|
||||
DateTimeOffset.UtcNow), CancellationToken.None);
|
||||
var feed = await client.GetFromJsonAsync<ActivityListResponse>("/api/v1/activity?limit=1");
|
||||
Assert.Single(feed!.Items);
|
||||
Assert.Equal("page.created", feed.Items[0].Action);
|
||||
var raw = await scope.ServiceProvider.GetRequiredService<IAuditEventReader>().ListRecentAsync(10, CancellationToken.None);
|
||||
Assert.Equal(3, raw.Count(item => item.Action == "token.rotated"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Activity_feed_returns_real_append_only_audit_events_in_reverse_chronological_order()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Operations;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Operations;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
public sealed class BackupEvidenceTests : IDisposable
|
||||
{
|
||||
private readonly string root = Path.Combine(Path.GetTempPath(), "kh-evidence-" + Guid.NewGuid().ToString("N"));
|
||||
private string BackupsRoot => Path.Combine(root, "backups");
|
||||
private string KeysRoot => Path.Combine(root, "keys");
|
||||
|
||||
[Fact]
|
||||
public async Task Historical_manifest_has_unknown_integrity_and_restore_until_actual_evidence_is_recorded()
|
||||
{
|
||||
var store = CreateStore("backup-one");
|
||||
var initial = await store.GetAsync("backup-one", CancellationToken.None);
|
||||
Assert.NotNull(initial);
|
||||
Assert.Null(initial.IntegrityVerifiedAt);
|
||||
Assert.Null(initial.RestoreVerifiedAt);
|
||||
Assert.False(initial.Protected);
|
||||
|
||||
await WriteValidIntegrityAsync("backup-one");
|
||||
await store.RecordRestoreAsync("backup-one", new RestoreReport("backup-one", true,
|
||||
[new RestoreTableReport("pages", 3, 3)], 1, 1, [], ManifestHash("backup-one")), CancellationToken.None);
|
||||
var verified = await store.GetAsync("backup-one", CancellationToken.None);
|
||||
Assert.NotNull(verified!.RestoreVerifiedAt);
|
||||
// Changing the manifest invalidates previously verified evidence.
|
||||
await File.AppendAllTextAsync(Path.Combine(BackupsRoot, "backup-one", "manifest.json"), " ");
|
||||
Assert.Null((await store.GetAsync("backup-one", CancellationToken.None))!.RestoreVerifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Manifest_changed_during_restore_cannot_receive_successful_receipt()
|
||||
{
|
||||
var store = CreateStore("backup-changed");
|
||||
await WriteValidIntegrityAsync("backup-changed");
|
||||
var digest = ManifestHash("backup-changed");
|
||||
await File.AppendAllTextAsync(Path.Combine(BackupsRoot, "backup-changed", "manifest.json"), " ");
|
||||
await Assert.ThrowsAsync<IOException>(() => store.RecordRestoreAsync("backup-changed",
|
||||
new RestoreReport("backup-changed", true, [], 0, 0, [], digest), CancellationToken.None));
|
||||
Assert.Null((await store.GetAsync("backup-changed", CancellationToken.None))!.RestoreVerifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Integrity_evidence_is_bound_to_the_exact_manifest_hash_used_for_staging()
|
||||
{
|
||||
const string backupId = "backup-bound-manifest";
|
||||
var store = CreateStore(backupId);
|
||||
await WriteValidIntegrityAsync(backupId);
|
||||
var manifestHash = ManifestHash(backupId);
|
||||
|
||||
var matching = await store.GetAsync(backupId, CancellationToken.None, manifestHash);
|
||||
var mismatching = await store.GetAsync(backupId, CancellationToken.None, new string('0', 64));
|
||||
|
||||
Assert.NotNull(matching);
|
||||
Assert.NotNull(matching.IntegrityVerifiedAt);
|
||||
Assert.NotNull(mismatching);
|
||||
Assert.Null(mismatching.IntegrityVerifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forged_manifest_and_matching_unsigned_receipt_do_not_verify()
|
||||
{
|
||||
const string backupId = "backup-forged";
|
||||
var store = CreateStore(backupId);
|
||||
await WriteValidIntegrityAsync(backupId);
|
||||
|
||||
var manifestPath = Path.Combine(BackupsRoot, backupId, "manifest.json");
|
||||
var manifest = JsonSerializer.Deserialize<BackupManifest>(await File.ReadAllTextAsync(manifestPath));
|
||||
Assert.NotNull(manifest);
|
||||
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest with { ProductVersion = "forged" }));
|
||||
var changedHash = ManifestHash(backupId);
|
||||
var forgedReceipt = new
|
||||
{
|
||||
Version = 1,
|
||||
BackupId = backupId,
|
||||
ManifestSha256 = changedHash,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
Success = true,
|
||||
HmacSha256 = new string('0', 64)
|
||||
};
|
||||
await File.WriteAllTextAsync(Path.Combine(BackupsRoot, backupId, ".integrity.json"),
|
||||
JsonSerializer.Serialize(forgedReceipt));
|
||||
|
||||
var evidence = await store.GetAsync(backupId, CancellationToken.None);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Null(evidence.IntegrityVerifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_integrity_key_invalidates_a_previously_signed_receipt()
|
||||
{
|
||||
const string backupId = "backup-key-lost";
|
||||
var store = CreateStore(backupId);
|
||||
await WriteValidIntegrityAsync(backupId);
|
||||
Assert.NotNull((await store.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt);
|
||||
|
||||
File.Delete(Path.Combine(KeysRoot, "backup-integrity.key"));
|
||||
|
||||
Assert.Null((await store.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Existing_unix_integrity_key_is_restricted_to_owner_read_write_before_use()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
|
||||
const string backupId = "backup-key-mode";
|
||||
var store = CreateStore(backupId);
|
||||
await WriteValidIntegrityAsync(backupId);
|
||||
var keyPath = Path.Combine(KeysRoot, "backup-integrity.key");
|
||||
File.SetUnixFileMode(keyPath,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
|
||||
|
||||
var evidence = await store.GetAsync(backupId, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(evidence);
|
||||
Assert.NotNull(evidence.IntegrityVerifiedAt);
|
||||
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(keyPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Protection_is_versioned_and_stale_write_cannot_remove_it()
|
||||
{
|
||||
var store = CreateStore("backup-two");
|
||||
var protectedBackup = await store.SetProtectionAsync("backup-two", true, 0, Guid.NewGuid(), CancellationToken.None);
|
||||
Assert.Equal(BackupProtectionResult.Updated, protectedBackup);
|
||||
Assert.Equal(BackupProtectionResult.Conflict,
|
||||
await store.SetProtectionAsync("backup-two", false, 0, Guid.NewGuid(), CancellationToken.None));
|
||||
var state = await store.GetAsync("backup-two", CancellationToken.None);
|
||||
Assert.True(state!.Protected);
|
||||
Assert.Equal(1, state.ProtectionVersion);
|
||||
Assert.Null(await store.GetAsync("../escape", CancellationToken.None));
|
||||
}
|
||||
|
||||
private FileBackupEvidenceStore CreateStore(string id)
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(BackupsRoot, id));
|
||||
File.WriteAllText(Path.Combine(BackupsRoot, id, "manifest.json"), JsonSerializer.Serialize(
|
||||
new BackupManifest(id, DateTimeOffset.UtcNow, "Hub", "1.0", "development", "schema", [], [], 0)));
|
||||
return new FileBackupEvidenceStore(new BackupPaths(BackupsRoot, Path.Combine(root, "attachments"), KeysRoot), TimeProvider.System);
|
||||
}
|
||||
|
||||
private string ManifestHash(string id) => Convert.ToHexStringLower(
|
||||
SHA256.HashData(File.ReadAllBytes(Path.Combine(BackupsRoot, id, "manifest.json"))));
|
||||
|
||||
private async Task WriteValidIntegrityAsync(string id)
|
||||
{
|
||||
Directory.CreateDirectory(KeysRoot);
|
||||
var keyPath = Path.Combine(KeysRoot, "backup-integrity.key");
|
||||
if (!File.Exists(keyPath))
|
||||
await File.WriteAllBytesAsync(keyPath, RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
var key = await File.ReadAllBytesAsync(keyPath);
|
||||
var completedAt = DateTimeOffset.UtcNow;
|
||||
var manifestHash = ManifestHash(id);
|
||||
var payload = string.Join('\n',
|
||||
"itworx-knowledge-hub-backup-evidence-v1",
|
||||
"integrity",
|
||||
id,
|
||||
manifestHash,
|
||||
completedAt.UtcTicks.ToString(CultureInfo.InvariantCulture),
|
||||
"1");
|
||||
var record = new
|
||||
{
|
||||
Version = 1,
|
||||
BackupId = id,
|
||||
ManifestSha256 = manifestHash,
|
||||
CompletedAt = completedAt,
|
||||
Success = true,
|
||||
HmacSha256 = Convert.ToHexStringLower(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(payload)))
|
||||
};
|
||||
await File.WriteAllTextAsync(Path.Combine(BackupsRoot, id, ".integrity.json"),
|
||||
JsonSerializer.Serialize(record));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
+80
-2
@@ -1,7 +1,10 @@
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Operations;
|
||||
using ItWorx.KnowledgeHub.Contracts.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Operations;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
@@ -10,9 +13,16 @@ namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
/// 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" })
|
||||
public sealed class BackupServiceTests : OwnerSetupTestBase
|
||||
{
|
||||
private readonly PostgresContainerFixture _postgres;
|
||||
|
||||
public BackupServiceTests(PostgresContainerFixture postgres)
|
||||
: base(postgres, new Dictionary<string, string?> { ["KnowledgeHub:Backup:RetainedCount"] = "2" })
|
||||
{
|
||||
_postgres = postgres;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_backup_captures_every_table_and_verifies_its_own_hashes()
|
||||
{
|
||||
@@ -23,6 +33,10 @@ public sealed class BackupServiceTests(PostgresContainerFixture postgres)
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
var database = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var appliedMigrations = await database.Database.GetAppliedMigrationsAsync();
|
||||
Assert.NotEmpty(appliedMigrations);
|
||||
var latestAppliedMigration = appliedMigrations.Last();
|
||||
|
||||
var summary = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
|
||||
@@ -31,12 +45,31 @@ public sealed class BackupServiceTests(PostgresContainerFixture postgres)
|
||||
|
||||
var manifest = await backups.GetManifestAsync(summary.BackupId, CancellationToken.None);
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal(latestAppliedMigration, manifest.SchemaVersion);
|
||||
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");
|
||||
var receipt = await scope.ServiceProvider.GetRequiredService<IBackupEvidenceStore>().GetAsync(summary.BackupId, CancellationToken.None);
|
||||
Assert.NotNull(receipt!.IntegrityVerifiedAt);
|
||||
Assert.Null(receipt.RestoreVerifiedAt);
|
||||
AssertBackupFilesArePrivate(
|
||||
scope.ServiceProvider.GetRequiredService<BackupPaths>(), summary.BackupId);
|
||||
|
||||
// Restore the binary dump into a separate migrated database and verify every
|
||||
// manifest row count against the rows actually written by PostgreSQL COPY.
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
|
||||
var restore = scope.ServiceProvider.GetRequiredService<IRestoreService>();
|
||||
var report = await restore.RestoreAsync(
|
||||
summary.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}: restored {table.RestoredRowCount}, manifest expected {table.ExpectedRowCount}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,4 +118,49 @@ public sealed class BackupServiceTests(PostgresContainerFixture postgres)
|
||||
Assert.Equal(second.BackupId, list[1].BackupId);
|
||||
Assert.DoesNotContain(list, entry => entry.BackupId == first.BackupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Protected_backup_survives_retention_of_newer_unprotected_copies()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
var evidence = scope.ServiceProvider.GetRequiredService<IBackupEvidenceStore>();
|
||||
var protectedBackup = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
Assert.Equal(BackupProtectionResult.Updated,
|
||||
await evidence.SetProtectionAsync(protectedBackup.BackupId, true, 0, Guid.NewGuid(), CancellationToken.None));
|
||||
await backups.CreateBackupAsync(CancellationToken.None);
|
||||
await backups.CreateBackupAsync(CancellationToken.None);
|
||||
var latest = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
var list = await backups.ListBackupsAsync(CancellationToken.None);
|
||||
Assert.Contains(list, backup => backup.BackupId == protectedBackup.BackupId);
|
||||
Assert.Contains(list, backup => backup.BackupId == latest.BackupId);
|
||||
Assert.Equal(3, list.Count);
|
||||
}
|
||||
|
||||
private static void AssertBackupFilesArePrivate(BackupPaths paths, string backupId)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
|
||||
var backupDirectory = Path.Combine(paths.BackupsRoot, backupId);
|
||||
Assert.Equal(
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
|
||||
File.GetUnixFileMode(paths.BackupsRoot));
|
||||
var directories = Directory.EnumerateDirectories(backupDirectory, "*", SearchOption.AllDirectories)
|
||||
.Prepend(backupDirectory);
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
Assert.Equal(
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
|
||||
File.GetUnixFileMode(directory));
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(backupDirectory, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(file));
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite,
|
||||
File.GetUnixFileMode(Path.Combine(paths.BackupsRoot, ".backup.lock")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class DataSourceApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
private async Task<HttpClient> ClientAsync()
|
||||
{
|
||||
var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_registered_source_is_persisted_as_unknown_and_requires_concurrency_for_disable()
|
||||
{
|
||||
using var client = await ClientAsync();
|
||||
using var created = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/admin/data-sources", new { sourceKey = $"test:{Guid.NewGuid():N}", displayName = "Test source", kind = "Inventory", expectedIntervalHours = 24 });
|
||||
Assert.Equal(HttpStatusCode.OK, created.StatusCode);
|
||||
using var json = JsonDocument.Parse(await created.Content.ReadAsStringAsync());
|
||||
var id = json.RootElement.GetProperty("id").GetGuid();
|
||||
Assert.Equal("Unknown", json.RootElement.GetProperty("state").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, json.RootElement.GetProperty("lastSuccessAt").ValueKind);
|
||||
using var missing = await SetupHelper.PutWithCsrfAsync(client, $"/api/v1/admin/data-sources/{id}", new { displayName = "Test source", enabled = false, expectedIntervalHours = 24 });
|
||||
Assert.Equal((HttpStatusCode)428, missing.StatusCode);
|
||||
using var changed = await SetupHelper.PutWithCsrfAsync(client, $"/api/v1/admin/data-sources/{id}", new { displayName = "Test source", enabled = false, expectedIntervalHours = 24 }, "1");
|
||||
Assert.Equal(HttpStatusCode.OK, changed.StatusCode);
|
||||
using var stale = await SetupHelper.PutWithCsrfAsync(client, $"/api/v1/admin/data-sources/{id}", new { displayName = "Stale writer", enabled = true, expectedIntervalHours = 24 }, "1");
|
||||
Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode);
|
||||
using var response = await client.GetAsync("/api/v1/admin/data-sources?limit=100");
|
||||
using var list = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var row = list.RootElement.GetProperty("items").EnumerateArray().Single(item => item.GetProperty("id").GetGuid() == id);
|
||||
Assert.Equal("Disabled", row.GetProperty("state").GetString());
|
||||
Assert.Equal("Test source", row.GetProperty("displayName").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_inventory_importer_key_cannot_be_registered_as_an_operator_source()
|
||||
{
|
||||
using var client = await ClientAsync();
|
||||
foreach (var kind in new[] { "Gitea", "Inventory" })
|
||||
{
|
||||
using var response = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/admin/data-sources",
|
||||
new { sourceKey = "inventory:default", displayName = "Reserved source", kind, expectedIntervalHours = 24 });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
using var list = await client.GetAsync("/api/v1/admin/data-sources?limit=100");
|
||||
using var json = JsonDocument.Parse(await list.Content.ReadAsStringAsync());
|
||||
Assert.Empty(json.RootElement.GetProperty("items").EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unauthenticated_mutations_and_missing_csrf_do_not_register_sources()
|
||||
{
|
||||
using var anonymous = Factory.CreateClient();
|
||||
using var denied = await anonymous.GetAsync("/api/v1/admin/data-sources");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
|
||||
using var owner = await ClientAsync();
|
||||
using var csrfDenied = await owner.PostAsync("/api/v1/admin/data-sources", new StringContent("{}", System.Text.Encoding.UTF8, "application/json"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, csrfDenied.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using ItWorx.KnowledgeHub.Application.Operations;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Exporter;
|
||||
using OpenTelemetry.Metrics;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
public sealed class OtlpExportTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Platform_metrics_are_exported_over_real_OTLP_HTTP_without_private_labels()
|
||||
{
|
||||
var received = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.WebHost.ConfigureKestrel(server => server.Listen(IPAddress.Loopback, 0));
|
||||
await using var collector = builder.Build();
|
||||
collector.MapPost("/v1/metrics", async context =>
|
||||
{
|
||||
using var body = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(body);
|
||||
received.TrySetResult(body.ToArray());
|
||||
context.Response.ContentType = "application/x-protobuf";
|
||||
context.Response.StatusCode = 200;
|
||||
});
|
||||
await collector.StartAsync();
|
||||
var address = collector.Services.GetRequiredService<IServer>()
|
||||
.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
|
||||
using var metrics = new PlatformMetrics();
|
||||
using var provider = Sdk.CreateMeterProviderBuilder().AddMeter("ItWorx.KnowledgeHub.Platform")
|
||||
.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(new Uri(address), "/v1/metrics");
|
||||
options.Protocol = OtlpExportProtocol.HttpProtobuf;
|
||||
}).Build();
|
||||
metrics.Record("private-document-title", "secret-token", .125, 2);
|
||||
metrics.SetQueueState(2, 1, 0, 90);
|
||||
metrics.SetOperationalAges(30, 60, 120);
|
||||
Assert.True(provider.ForceFlush(10_000));
|
||||
var payload = Encoding.UTF8.GetString(await received.Task.WaitAsync(TimeSpan.FromSeconds(10)));
|
||||
Assert.Contains("platform_operations_total", payload, StringComparison.Ordinal);
|
||||
Assert.Contains("platform_evidence_age_seconds", payload, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private-document-title", payload, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("secret-token", payload, StringComparison.Ordinal);
|
||||
await collector.StopAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using ItWorx.KnowledgeHub.Application.Operations;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
public sealed class PlatformMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Metrics_reject_unbounded_operation_and_outcome_dimensions()
|
||||
{
|
||||
var observed = new List<KeyValuePair<string, object?>>();
|
||||
using var listener = new MeterListener();
|
||||
listener.InstrumentPublished = (instrument, current) =>
|
||||
{
|
||||
if (instrument.Meter.Name == "ItWorx.KnowledgeHub.Platform") current.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
listener.SetMeasurementEventCallback<double>((_, _, tags, _) => observed.AddRange(tags.ToArray()));
|
||||
listener.SetMeasurementEventCallback<long>((_, _, tags, _) => observed.AddRange(tags.ToArray()));
|
||||
listener.Start();
|
||||
using var metrics = new PlatformMetrics();
|
||||
metrics.Record("private query and bearer token", "secret", 0.1, 3);
|
||||
Assert.NotEmpty(observed);
|
||||
Assert.All(observed, tag => Assert.Contains(tag.Key, new[] { "operation", "outcome" }));
|
||||
Assert.DoesNotContain(observed, tag => tag.Value?.ToString()?.Contains("secret", StringComparison.Ordinal) == true);
|
||||
Assert.Contains(observed, tag => tag.Value?.ToString() == "other");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.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.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
|
||||
@@ -57,6 +61,10 @@ public sealed class RestoreServiceTests : OwnerSetupTestBase
|
||||
Assert.True(pagesTable.RestoredRowCount >= 1);
|
||||
Assert.Equal(1, report.AttachmentsRestored);
|
||||
Assert.Equal(report.AttachmentsExpected, report.AttachmentsRestored);
|
||||
var receipt = await sourceScope.ServiceProvider.GetRequiredService<IBackupEvidenceStore>()
|
||||
.GetAsync(backup.BackupId, CancellationToken.None);
|
||||
Assert.NotNull(receipt!.RestoreVerifiedAt);
|
||||
Assert.True(receipt.LastRestoreSucceeded);
|
||||
|
||||
var restoredAttachmentFiles = Directory.GetFiles(target.AttachmentsDirectory, "*", SearchOption.AllDirectories);
|
||||
Assert.Single(restoredAttachmentFiles);
|
||||
@@ -67,6 +75,208 @@ public sealed class RestoreServiceTests : OwnerSetupTestBase
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_backup_from_the_previous_schema_restores_before_the_target_is_upgraded()
|
||||
{
|
||||
// Both databases belong to this test. Seeded owner/workspace data survives
|
||||
// restore at its original schema and the subsequent additive upgrade.
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var sourceDb = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
const string previousSchema = "20260924182209_AddProjectIcons";
|
||||
await sourceDb.GetService<IMigrator>().MigrateAsync(previousSchema);
|
||||
var originalWorkspace = await sourceDb.Workspaces.AsNoTracking().SingleAsync();
|
||||
var backup = await scope.ServiceProvider.GetRequiredService<IBackupService>().CreateBackupAsync(CancellationToken.None);
|
||||
Assert.Equal(previousSchema, backup.SchemaVersion);
|
||||
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
|
||||
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
|
||||
.UseNpgsql(target.ConnectionString).UseSnakeCaseNamingConvention().Options;
|
||||
await using var db = new KnowledgeHubDbContext(options);
|
||||
await db.GetService<IMigrator>().MigrateAsync(previousSchema);
|
||||
var report = await scope.ServiceProvider.GetRequiredService<IRestoreService>().RestoreAsync(
|
||||
backup.BackupId, new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory), CancellationToken.None);
|
||||
Assert.True(report.Success);
|
||||
await db.Database.MigrateAsync();
|
||||
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
|
||||
Assert.Equal(originalWorkspace.Id, (await db.Workspaces.AsNoTracking().SingleAsync()).Id);
|
||||
Assert.Equal(OwnerEmail, (await db.Users.AsNoTracking().SingleAsync()).Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_different_target_schema_is_rejected_before_any_rows_are_restored()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var backup = await scope.ServiceProvider.GetRequiredService<IBackupService>().CreateBackupAsync(CancellationToken.None);
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
|
||||
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
|
||||
.UseNpgsql(target.ConnectionString).UseSnakeCaseNamingConvention().Options;
|
||||
await using var db = new KnowledgeHubDbContext(options);
|
||||
await db.GetService<IMigrator>().MigrateAsync("20260924182209_AddProjectIcons");
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
scope.ServiceProvider.GetRequiredService<IRestoreService>().RestoreAsync(
|
||||
backup.BackupId, new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory), CancellationToken.None));
|
||||
|
||||
Assert.Contains("doelschema", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Empty(await db.Workspaces.ToListAsync());
|
||||
Assert.Empty(await db.Users.ToListAsync());
|
||||
Assert.Empty(Directory.EnumerateFileSystemEntries(target.AttachmentsDirectory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_truncated_manifest_cannot_claim_a_successful_restore()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
using var sourceScope = Factory.Services.CreateScope();
|
||||
var backups = sourceScope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
var backup = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
var manifest = await backups.GetManifestAsync(backup.BackupId, CancellationToken.None);
|
||||
Assert.NotNull(manifest);
|
||||
var manifestPath = Path.Combine(Factory.BackupsDirectory, backup.BackupId, "manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest with { Tables = [] }));
|
||||
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
|
||||
var restore = sourceScope.ServiceProvider.GetRequiredService<IRestoreService>();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => restore.RestoreAsync(
|
||||
backup.BackupId,
|
||||
new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory),
|
||||
CancellationToken.None));
|
||||
var receipt = await sourceScope.ServiceProvider.GetRequiredService<IBackupEvidenceStore>()
|
||||
.GetAsync(backup.BackupId, CancellationToken.None);
|
||||
Assert.Null(receipt!.RestoreVerifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_forged_manifest_and_matching_receipt_cannot_authenticate_an_altered_dump()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
using var sourceScope = Factory.Services.CreateScope();
|
||||
var backups = sourceScope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
var backup = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
var manifest = await backups.GetManifestAsync(backup.BackupId, CancellationToken.None);
|
||||
Assert.NotNull(manifest);
|
||||
var table = Assert.Single(manifest.Tables, entry => entry.TableName == "pages");
|
||||
var tablePath = Path.Combine(Factory.BackupsDirectory, backup.BackupId, "db", table.FileName);
|
||||
|
||||
await using (var dump = new FileStream(tablePath, FileMode.Append, FileAccess.Write, FileShare.None))
|
||||
dump.WriteByte(0x5A);
|
||||
await using var alteredDump = File.OpenRead(tablePath);
|
||||
var alteredDumpHash = Convert.ToHexStringLower(await SHA256.HashDataAsync(alteredDump));
|
||||
var alteredTable = table with { SizeBytes = new FileInfo(tablePath).Length, Sha256 = alteredDumpHash };
|
||||
var forgedManifest = manifest with
|
||||
{
|
||||
ProductVersion = "forged",
|
||||
Tables = manifest.Tables.Select(entry => entry.TableName == table.TableName ? alteredTable : entry).ToArray(),
|
||||
TotalSizeBytes = manifest.TotalSizeBytes + 1
|
||||
};
|
||||
var manifestPath = Path.Combine(Factory.BackupsDirectory, backup.BackupId, "manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(forgedManifest));
|
||||
var forgedManifestHash = Convert.ToHexStringLower(SHA256.HashData(await File.ReadAllBytesAsync(manifestPath)));
|
||||
var forgedReceipt = new
|
||||
{
|
||||
Version = 1,
|
||||
BackupId = backup.BackupId,
|
||||
ManifestSha256 = forgedManifestHash,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
Success = true,
|
||||
HmacSha256 = new string('0', 64)
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(Factory.BackupsDirectory, backup.BackupId, ".integrity.json"),
|
||||
JsonSerializer.Serialize(forgedReceipt));
|
||||
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
|
||||
var restore = sourceScope.ServiceProvider.GetRequiredService<IRestoreService>();
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => restore.RestoreAsync(
|
||||
backup.BackupId,
|
||||
new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory),
|
||||
CancellationToken.None));
|
||||
Assert.Contains("integriteit", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var receipt = await sourceScope.ServiceProvider.GetRequiredService<IBackupEvidenceStore>()
|
||||
.GetAsync(backup.BackupId, CancellationToken.None);
|
||||
Assert.Null(receipt!.IntegrityVerifiedAt);
|
||||
Assert.Null(receipt.RestoreVerifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_uses_the_verified_snapshot_if_backup_files_change_during_restore()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
var pageResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
client, "/api/v1/pages", new CreatePageRequest(null, "TOCTOU-bronpagina", "WikiPage", "Inhoud.", null));
|
||||
var page = await pageResponse.Content.ReadFromJsonAsync<PageDetail>();
|
||||
Assert.NotNull(page);
|
||||
var originalContents = "signed backup attachment"u8.ToArray();
|
||||
var uploadResponse = await SetupHelper.PostMultipartWithCsrfAsync(
|
||||
client, $"/api/v1/pages/{page.Id}/attachments", originalContents, "restore-race.txt", "text/plain");
|
||||
Assert.True(uploadResponse.IsSuccessStatusCode);
|
||||
|
||||
using var sourceScope = Factory.Services.CreateScope();
|
||||
var backups = sourceScope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
var backup = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
var manifest = await backups.GetManifestAsync(backup.BackupId, CancellationToken.None);
|
||||
Assert.NotNull(manifest);
|
||||
var attachment = Assert.Single(manifest.Attachments);
|
||||
var table = Assert.Single(manifest.Tables, entry => entry.TableName == "pages");
|
||||
var sourceTable = Path.Combine(Factory.BackupsDirectory, backup.BackupId, "db", table.FileName);
|
||||
var sourceAttachment = Path.Combine(Factory.BackupsDirectory, backup.BackupId, "attachments",
|
||||
attachment.RelativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
|
||||
var applicationName = "restore-toctou-" + Guid.NewGuid().ToString("N");
|
||||
var targetConnectionString = new NpgsqlConnectionStringBuilder(target.ConnectionString)
|
||||
{
|
||||
ApplicationName = applicationName
|
||||
}.ConnectionString;
|
||||
await using var lockConnection = new NpgsqlConnection(targetConnectionString);
|
||||
await lockConnection.OpenAsync();
|
||||
await using var lockTransaction = await lockConnection.BeginTransactionAsync();
|
||||
await using (var lockTable = new NpgsqlCommand(
|
||||
"LOCK TABLE public.pages IN ACCESS EXCLUSIVE MODE", lockConnection, lockTransaction))
|
||||
{
|
||||
await lockTable.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var restore = sourceScope.ServiceProvider.GetRequiredService<IRestoreService>();
|
||||
var restoreTask = restore.RestoreAsync(
|
||||
backup.BackupId, new RestoreTarget(targetConnectionString, target.AttachmentsDirectory), CancellationToken.None);
|
||||
var reachedBlockedTargetCheck = await WaitForRestoreLockWaitAsync(applicationName);
|
||||
await using (var dump = new FileStream(sourceTable, FileMode.Append, FileAccess.Write, FileShare.None))
|
||||
await dump.WriteAsync("changed after staging"u8.ToArray());
|
||||
await File.WriteAllBytesAsync(sourceAttachment, "replaced after verification"u8.ToArray());
|
||||
await lockTransaction.CommitAsync();
|
||||
|
||||
var report = await restoreTask;
|
||||
Assert.True(reachedBlockedTargetCheck, "The restore should have reached the target database after source verification.");
|
||||
Assert.True(report.Success, string.Join("; ", report.Warnings));
|
||||
Assert.True(Assert.Single(report.Tables, restored => restored.TableName == "pages").Matches);
|
||||
var restoredAttachment = Path.Combine(target.AttachmentsDirectory,
|
||||
attachment.RelativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
Assert.Equal(originalContents, await File.ReadAllBytesAsync(restoredAttachment));
|
||||
}
|
||||
|
||||
private async Task<bool> WaitForRestoreLockWaitAsync(string applicationName)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(15);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await using var observer = new NpgsqlConnection(_postgres.AdminConnectionString);
|
||||
await observer.OpenAsync();
|
||||
await using var command = new NpgsqlCommand(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE application_name = @application_name AND wait_event_type = 'Lock')",
|
||||
observer);
|
||||
command.Parameters.AddWithValue("application_name", applicationName);
|
||||
if ((bool)(await command.ExecuteScalarAsync() ?? false)) return true;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restoring_into_a_non_empty_target_is_refused()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
@@ -22,7 +25,10 @@ public sealed class SystemInfoTests(PostgresContainerFixture postgres) : OwnerSe
|
||||
var payload = await response.Content.ReadFromJsonAsync<SystemInfoPayload>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("ItWorx Knowledge Hub", payload.Product);
|
||||
Assert.Equal("m7", payload.SchemaVersion);
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var currentMigration = (await db.Database.GetAppliedMigrationsAsync()).Last();
|
||||
Assert.Equal(currentMigration, payload.SchemaVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Operations;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Operations;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class TableIdentifierSafetyTests : OwnerSetupTestBase
|
||||
{
|
||||
private readonly PostgresContainerFixture _postgres;
|
||||
|
||||
public TableIdentifierSafetyTests(PostgresContainerFixture postgres) : base(postgres)
|
||||
{
|
||||
_postgres = postgres;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("unsafe\";SELECT 1;--")]
|
||||
[InlineData("unsafe';SELECT 1;--")]
|
||||
[InlineData("unsafe;SELECT 1;--")]
|
||||
public async Task Backup_rejects_catalog_identifiers_before_building_copy_sql(string tableName)
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
await db.Database.OpenConnectionAsync();
|
||||
var connection = (NpgsqlConnection)db.Database.GetDbConnection();
|
||||
var quotedName = QuoteIdentifier(tableName);
|
||||
await using (var create = new NpgsqlCommand($"CREATE TABLE public.{quotedName} (id integer)", connection))
|
||||
await create.ExecuteNonQueryAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
backups.CreateBackupAsync(CancellationToken.None));
|
||||
Assert.Contains("identificatie", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await using var drop = new NpgsqlCommand($"DROP TABLE IF EXISTS public.{quotedName}", connection);
|
||||
await drop.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("unsafe\";SELECT 1;--")]
|
||||
[InlineData("unsafe';SELECT 1;--")]
|
||||
[InlineData("unsafe;SELECT 1;--")]
|
||||
public async Task Restore_rejects_a_validly_signed_manifest_identifier_before_target_connection(string tableName)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "kh-identifier-" + Guid.NewGuid().ToString("N"));
|
||||
var backupsRoot = Path.Combine(root, "backups");
|
||||
var backupId = "signed-identifier-test";
|
||||
var backupDirectory = Path.Combine(backupsRoot, backupId);
|
||||
var keysRoot = Path.Combine(root, "keys");
|
||||
var attachmentsRoot = Path.Combine(root, "source-attachments");
|
||||
var targetAttachments = Path.Combine(root, "target-attachments");
|
||||
Directory.CreateDirectory(backupDirectory);
|
||||
Directory.CreateDirectory(keysRoot);
|
||||
Directory.CreateDirectory(attachmentsRoot);
|
||||
Directory.CreateDirectory(targetAttachments);
|
||||
|
||||
try
|
||||
{
|
||||
await WriteSignedManifestAsync(backupDirectory, keysRoot, backupId, tableName);
|
||||
var paths = new BackupPaths(backupsRoot, attachmentsRoot, keysRoot);
|
||||
var evidence = new FileBackupEvidenceStore(paths, TimeProvider.System);
|
||||
Assert.NotNull((await evidence.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt);
|
||||
var restore = new PostgresRestoreService(paths, evidence);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => restore.RestoreAsync(
|
||||
backupId,
|
||||
new RestoreTarget(
|
||||
"Host=127.0.0.1;Port=1;Database=must-not-connect;Username=test;Password=test;Timeout=1",
|
||||
targetAttachments),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("identificatie", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(Directory.EnumerateFileSystemEntries(targetAttachments));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_rejects_duplicate_signed_manifest_tables_before_target_connection()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "kh-identifier-" + Guid.NewGuid().ToString("N"));
|
||||
var backupsRoot = Path.Combine(root, "backups");
|
||||
var backupId = "duplicate-table-test";
|
||||
var backupDirectory = Path.Combine(backupsRoot, backupId);
|
||||
var keysRoot = Path.Combine(root, "keys");
|
||||
var attachmentsRoot = Path.Combine(root, "source-attachments");
|
||||
var targetAttachments = Path.Combine(root, "target-attachments");
|
||||
Directory.CreateDirectory(backupDirectory);
|
||||
Directory.CreateDirectory(keysRoot);
|
||||
Directory.CreateDirectory(attachmentsRoot);
|
||||
Directory.CreateDirectory(targetAttachments);
|
||||
|
||||
try
|
||||
{
|
||||
await WriteSignedManifestAsync(backupDirectory, keysRoot, backupId, "pages", duplicateTable: true);
|
||||
var paths = new BackupPaths(backupsRoot, attachmentsRoot, keysRoot);
|
||||
var evidence = new FileBackupEvidenceStore(paths, TimeProvider.System);
|
||||
Assert.NotNull((await evidence.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt);
|
||||
var restore = new PostgresRestoreService(paths, evidence);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => restore.RestoreAsync(
|
||||
backupId,
|
||||
new RestoreTarget(
|
||||
"Host=127.0.0.1;Port=1;Database=must-not-connect;Username=test;Password=test;Timeout=1",
|
||||
targetAttachments),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("dubbele tabelnamen", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(Directory.EnumerateFileSystemEntries(targetAttachments));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_rejects_unsafe_identity_column_names_before_committing_target_rows()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var backups = scope.ServiceProvider.GetRequiredService<IBackupService>();
|
||||
var backup = await backups.CreateBackupAsync(CancellationToken.None);
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(_postgres);
|
||||
|
||||
var unsafeColumn = QuoteIdentifier("unsafe';SELECT 1;--");
|
||||
await using (var connection = new NpgsqlConnection(target.ConnectionString))
|
||||
{
|
||||
await connection.OpenAsync();
|
||||
await using var create = new NpgsqlCommand(
|
||||
$"CREATE TABLE public.safe_identity_test ({unsafeColumn} integer GENERATED BY DEFAULT AS IDENTITY)", connection);
|
||||
await create.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var restore = scope.ServiceProvider.GetRequiredService<IRestoreService>();
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => restore.RestoreAsync(
|
||||
backup.BackupId,
|
||||
new RestoreTarget(target.ConnectionString, target.AttachmentsDirectory),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("identificatie", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
await using var verify = new NpgsqlConnection(target.ConnectionString);
|
||||
await verify.OpenAsync();
|
||||
await using var count = new NpgsqlCommand("SELECT count(*) FROM public.workspaces", verify);
|
||||
Assert.Equal(0L, (long)(await count.ExecuteScalarAsync() ?? 0L));
|
||||
Assert.Empty(Directory.EnumerateFileSystemEntries(target.AttachmentsDirectory));
|
||||
}
|
||||
|
||||
private static async Task WriteSignedManifestAsync(
|
||||
string backupDirectory, string keysRoot, string backupId, string tableName, bool duplicateTable = false)
|
||||
{
|
||||
var table = new BackupTableManifestEntry(tableName, "rows.bin", 0, 0, new string('0', 64));
|
||||
BackupTableManifestEntry[] tables = duplicateTable ? [table, table] : [table];
|
||||
var manifest = new BackupManifest(
|
||||
backupId, DateTimeOffset.UtcNow, "ItWorx Knowledge Hub", "1.0", "test", "schema",
|
||||
tables, [], 0);
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest);
|
||||
await File.WriteAllBytesAsync(Path.Combine(backupDirectory, "manifest.json"), manifestBytes);
|
||||
|
||||
var key = RandomNumberGenerator.GetBytes(32);
|
||||
await File.WriteAllBytesAsync(Path.Combine(keysRoot, "backup-integrity.key"), key);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
File.SetUnixFileMode(Path.Combine(keysRoot, "backup-integrity.key"),
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
var completedAt = DateTimeOffset.UtcNow;
|
||||
var manifestHash = Convert.ToHexStringLower(SHA256.HashData(manifestBytes));
|
||||
var payload = string.Join('\n',
|
||||
"itworx-knowledge-hub-backup-evidence-v1", "integrity", backupId, manifestHash,
|
||||
completedAt.UtcTicks.ToString(CultureInfo.InvariantCulture), "1");
|
||||
var receipt = new
|
||||
{
|
||||
Version = 1,
|
||||
BackupId = backupId,
|
||||
ManifestSha256 = manifestHash,
|
||||
CompletedAt = completedAt,
|
||||
Success = true,
|
||||
HmacSha256 = Convert.ToHexStringLower(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(payload)))
|
||||
};
|
||||
await File.WriteAllTextAsync(Path.Combine(backupDirectory, ".integrity.json"), JsonSerializer.Serialize(receipt));
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
|
||||
private static string QuoteIdentifier(string identifier) => $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using ItWorx.KnowledgeHub.Domain.Sources;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Platform;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class PlatformMigrationTests(PostgresContainerFixture postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Platform_upgrade_preserves_existing_workspace_and_supports_pre_use_schema_rollback()
|
||||
{
|
||||
// Only this disposable, freshly created database is ever downgraded. An
|
||||
// operator rollback after platform use must restore a pre-upgrade backup.
|
||||
await using var target = await RestoreTargetDatabase.CreateAsync(postgres);
|
||||
var options = new DbContextOptionsBuilder<KnowledgeHubDbContext>()
|
||||
.UseNpgsql(target.ConnectionString)
|
||||
.UseSnakeCaseNamingConvention().Options;
|
||||
await using var db = new KnowledgeHubDbContext(options);
|
||||
var migrator = db.GetService<IMigrator>();
|
||||
const string previousSchema = "20260924182209_AddProjectIcons";
|
||||
await migrator.MigrateAsync(previousSchema);
|
||||
var workspace = Workspace.Create(WorkspaceName.Create("Bestaande werkruimte"), DateTimeOffset.UtcNow);
|
||||
db.Workspaces.Add(workspace);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await migrator.MigrateAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
Assert.Equal("Bestaande werkruimte", (await db.Workspaces.SingleAsync()).Name);
|
||||
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
|
||||
Assert.Empty(await db.Set<DataSource>().ToListAsync());
|
||||
Assert.Empty(await db.UserPreferences.ToListAsync());
|
||||
Assert.Empty(await db.PlatformSettings.ToListAsync());
|
||||
Assert.Empty(await db.BackgroundJobs.ToListAsync());
|
||||
|
||||
await migrator.MigrateAsync(previousSchema);
|
||||
Assert.Equal(workspace.Id, (await db.Workspaces.AsNoTracking().SingleAsync()).Id);
|
||||
await migrator.MigrateAsync();
|
||||
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
|
||||
Assert.Equal(workspace.Id, (await db.Workspaces.AsNoTracking().SingleAsync()).Id);
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.Contracts.Ingestion;
|
||||
using ItWorx.KnowledgeHub.Contracts.Platform;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Platform;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class PlatformSettingsApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
private async Task<HttpClient> AuthenticatedClientAsync()
|
||||
{
|
||||
var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static PlatformSettingsRequest Settings(
|
||||
bool ingestionEnabled = false,
|
||||
int maximumSourceCount = 20,
|
||||
long maximumSourceBytes = 10_485_760) => new(
|
||||
"ItWorx Knowledge Hub Test",
|
||||
"nl-BE",
|
||||
"Europe/Brussels",
|
||||
new IngestionProposalPolicyContract(ingestionEnabled, maximumSourceCount, maximumSourceBytes, 2),
|
||||
new BackupPolicyExpectationRequest(24, "Extern beheerd", 7, 4, 6, 1, 90),
|
||||
new InventoryFreshnessPolicyContract(24));
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_settings_are_reported_as_environment_backed_defaults()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
|
||||
using var response = await client.GetAsync("/api/v1/admin/platform-settings");
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("\"0\"", response.Headers.ETag?.Tag);
|
||||
Assert.Equal("0", json.RootElement.GetProperty("eTag").GetString());
|
||||
Assert.True(json.RootElement.GetProperty("isDefault").GetBoolean());
|
||||
Assert.Equal("nl-BE", json.RootElement.GetProperty("primaryLocale").GetString());
|
||||
Assert.Equal("operator-managed", json.RootElement.GetProperty("backupPolicyExpectation").GetProperty("enforcement").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_replacement_persists_and_changes_the_effective_ingestion_policy()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
using var updated = await SetupHelper.PutWithCsrfAsync(
|
||||
client, "/api/v1/admin/platform-settings", Settings(ingestionEnabled: true, maximumSourceCount: 5), "0");
|
||||
using var updatedJson = JsonDocument.Parse(await updated.Content.ReadAsStringAsync());
|
||||
using var session = await client.GetAsync("/api/v1/auth/session");
|
||||
using var sessionJson = JsonDocument.Parse(await session.Content.ReadAsStringAsync());
|
||||
using var intake = await client.GetAsync("/api/v1/ingestion-proposals");
|
||||
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
|
||||
using var createRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/ingestion-proposals")
|
||||
{
|
||||
Content = JsonContent.Create(new CreateIngestionProposalRequest("Platform policy test", "settings-policy"))
|
||||
};
|
||||
createRequest.Headers.Add(csrf.HeaderName, csrf.Value);
|
||||
createRequest.Headers.Add("Idempotency-Key", "settings-policy-test");
|
||||
using var created = await client.SendAsync(createRequest);
|
||||
using var createdJson = JsonDocument.Parse(await created.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
|
||||
Assert.Equal("\"1\"", updated.Headers.ETag?.Tag);
|
||||
Assert.Equal("operator-managed", updatedJson.RootElement.GetProperty("backupPolicyExpectation").GetProperty("enforcement").GetString());
|
||||
Assert.True(sessionJson.RootElement.GetProperty("features").GetProperty("ingestionProposals").GetBoolean());
|
||||
Assert.Equal(HttpStatusCode.OK, intake.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
Assert.Equal(2, createdJson.RootElement.GetProperty("maxPublishRetries").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_settings_etags_and_values_over_the_intake_ceiling_are_rejected()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
|
||||
using var invalid = await SetupHelper.PutWithCsrfAsync(
|
||||
client, "/api/v1/admin/platform-settings", Settings(maximumSourceCount: 21), "0");
|
||||
using var current = await SetupHelper.PutWithCsrfAsync(
|
||||
client, "/api/v1/admin/platform-settings", Settings(), "0");
|
||||
using var stale = await SetupHelper.PutWithCsrfAsync(
|
||||
client, "/api/v1/admin/platform-settings", Settings(ingestionEnabled: true), "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, invalid.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, current.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Settings_updates_require_csrf_even_with_a_valid_etag()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/admin/platform-settings")
|
||||
{
|
||||
Content = System.Net.Http.Json.JsonContent.Create(Settings())
|
||||
};
|
||||
request.Headers.Add("If-Match", "\"0\"");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.Platform;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class UserPreferencesApiTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
private sealed record PreferencesPayload(
|
||||
string Theme,
|
||||
string Density,
|
||||
bool PrimaryNavigationCollapsed,
|
||||
bool ContextPanelOpen,
|
||||
string[] NavigationOrder,
|
||||
string[] DashboardWidgetOrder,
|
||||
string[] HiddenDashboardWidgets,
|
||||
string DefaultLandingPage,
|
||||
string CatalogView,
|
||||
int ContextPanelWidth,
|
||||
int NavigationWidth);
|
||||
|
||||
private static PreferencesPayload DefaultPreferences(string theme = "system") => new(
|
||||
theme,
|
||||
"comfortable",
|
||||
false,
|
||||
false,
|
||||
["/", "/infrastructure", "/infrastructure/services", "/projects", "/runbooks", "/knowledge", "/activity", "/search"],
|
||||
["attention", "server", "projects", "activity", "runbooks", "favorites", "quick-actions"],
|
||||
[],
|
||||
"/",
|
||||
"list",
|
||||
304,
|
||||
224);
|
||||
|
||||
private async Task<HttpClient> AuthenticatedClientAsync()
|
||||
{
|
||||
var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_preferences_return_the_shared_defaults_without_persisting_a_row()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
|
||||
using var first = await client.GetAsync("/api/v1/me/preferences");
|
||||
using var firstJson = JsonDocument.Parse(await first.Content.ReadAsStringAsync());
|
||||
using var second = await client.GetAsync("/api/v1/me/preferences");
|
||||
using var secondJson = JsonDocument.Parse(await second.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Equal("\"0\"", first.Headers.ETag?.Tag);
|
||||
Assert.Equal("0", firstJson.RootElement.GetProperty("eTag").GetString());
|
||||
Assert.True(firstJson.RootElement.GetProperty("isDefault").GetBoolean());
|
||||
Assert.Equal(1, firstJson.RootElement.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal("system", firstJson.RootElement.GetProperty("theme").GetString());
|
||||
Assert.Equal("comfortable", firstJson.RootElement.GetProperty("density").GetString());
|
||||
Assert.Equal(304, firstJson.RootElement.GetProperty("contextPanelWidth").GetInt32());
|
||||
Assert.Equal(224, firstJson.RootElement.GetProperty("navigationWidth").GetInt32());
|
||||
Assert.Equal("0", secondJson.RootElement.GetProperty("eTag").GetString());
|
||||
Assert.True(secondJson.RootElement.GetProperty("isDefault").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Full_replacement_with_the_current_etag_persists_for_the_signed_in_user()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var request = new PreferencesPayload(
|
||||
"dark", "compact", true, true,
|
||||
["/projects", "/", "/knowledge"], ["projects", "attention", "server"], ["server"],
|
||||
"/projects", "grid", 420, 248);
|
||||
|
||||
using var updated = await SetupHelper.PutWithCsrfAsync(client, "/api/v1/me/preferences", request, "0");
|
||||
using var updatedJson = JsonDocument.Parse(await updated.Content.ReadAsStringAsync());
|
||||
using var fetched = await client.GetAsync("/api/v1/me/preferences");
|
||||
using var fetchedJson = JsonDocument.Parse(await fetched.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
|
||||
Assert.Equal("\"1\"", updated.Headers.ETag?.Tag);
|
||||
Assert.Equal("1", updatedJson.RootElement.GetProperty("eTag").GetString());
|
||||
Assert.False(updatedJson.RootElement.GetProperty("isDefault").GetBoolean());
|
||||
Assert.Equal("dark", fetchedJson.RootElement.GetProperty("theme").GetString());
|
||||
Assert.Equal("/projects", fetchedJson.RootElement.GetProperty("defaultLandingPage").GetString());
|
||||
Assert.Equal(420, fetchedJson.RootElement.GetProperty("contextPanelWidth").GetInt32());
|
||||
Assert.Equal("1", fetchedJson.RootElement.GetProperty("eTag").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_missing_if_match_is_rejected_before_updating_preferences()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var request = DefaultPreferences("light");
|
||||
|
||||
using var response = await SetupHelper.PutWithCsrfAsync(client, "/api/v1/me/preferences", request);
|
||||
|
||||
Assert.Equal((HttpStatusCode)428, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stale_if_match_is_rejected_without_overwriting_a_newer_preference_version()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var request = DefaultPreferences("dark");
|
||||
|
||||
using var first = await SetupHelper.PutWithCsrfAsync(client, "/api/v1/me/preferences", request, "0");
|
||||
using var stale = await SetupHelper.PutWithCsrfAsync(client, "/api/v1/me/preferences", request with { Theme = "light" }, "0");
|
||||
using var fetched = await client.GetAsync("/api/v1/me/preferences");
|
||||
using var fetchedJson = JsonDocument.Parse(await fetched.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode);
|
||||
Assert.Equal("dark", fetchedJson.RootElement.GetProperty("theme").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preferences_reject_unknown_navigation_and_widget_ids()
|
||||
{
|
||||
using var client = await AuthenticatedClientAsync();
|
||||
var request = DefaultPreferences() with
|
||||
{
|
||||
NavigationOrder = ["/admin/users/delete"],
|
||||
DashboardWidgetOrder = ["unexpected-widget"],
|
||||
DefaultLandingPage = "/admin/users/delete"
|
||||
};
|
||||
|
||||
using var response = await SetupHelper.PutWithCsrfAsync(client, "/api/v1/me/preferences", request, "0");
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_first_writes_with_the_default_etag_have_one_winner()
|
||||
{
|
||||
using var firstClient = await AuthenticatedClientAsync();
|
||||
using var secondClient = await AuthenticatedClientAsync();
|
||||
var firstWrite = SetupHelper.PutWithCsrfAsync(firstClient, "/api/v1/me/preferences", DefaultPreferences("dark"), "0");
|
||||
var secondWrite = SetupHelper.PutWithCsrfAsync(secondClient, "/api/v1/me/preferences", DefaultPreferences("light"), "0");
|
||||
|
||||
using var firstResponse = await firstWrite;
|
||||
using var secondResponse = await secondWrite;
|
||||
|
||||
Assert.Equal(1, new[] { firstResponse.StatusCode, secondResponse.StatusCode }.Count(status => status == HttpStatusCode.OK));
|
||||
Assert.Equal(1, new[] { firstResponse.StatusCode, secondResponse.StatusCode }.Count(status => status == HttpStatusCode.PreconditionFailed));
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using ItWorx.KnowledgeHub.Contracts.UnifiedSearch;
|
||||
using ItWorx.KnowledgeHub.Domain.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Domain.Projects;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.UnifiedSearch;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SortKey = ItWorx.KnowledgeHub.Domain.Knowledge.SortKey;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.UnifiedSearch;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
[Trait("Category", "ReleaseScale")]
|
||||
public sealed class UnifiedSearchScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Unified_search_counts_and_cursor_pages_stay_bounded_with_10000_pages_and_2000_projects()
|
||||
{
|
||||
using var client = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var workspaceId = await db.Workspaces.Select(item => item.Id).SingleAsync();
|
||||
var actorId = await db.Users.Select(item => item.Id).SingleAsync();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sortKey = SortKey.First();
|
||||
|
||||
for (var offset = 0; offset < 10_000; offset += 500)
|
||||
{
|
||||
for (var index = offset; index < offset + 500; index++)
|
||||
{
|
||||
var title = index == 0 ? "Measure uniqueneedle page" : $"Measure page {index:D5}";
|
||||
var page = Page.Create(workspaceId, null, sortKey, PageTitle.Create(title), DocumentType.WikiPage,
|
||||
"Measure synthetic documentation with a bounded result snippet.", actorId, now);
|
||||
page.ReplaceTags(["platform-scale"]);
|
||||
db.Pages.Add(page);
|
||||
sortKey = SortKey.Next(sortKey);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
for (var offset = 0; offset < 2_000; offset += 500)
|
||||
{
|
||||
for (var index = offset; index < offset + 500; index++)
|
||||
{
|
||||
var title = index == 0 ? "Measure uniqueneedle project" : $"Measure project {index:D5}";
|
||||
db.Projects.Add(Project.Create(workspaceId, title, "Measure synthetic project summary.",
|
||||
ProjectPriority.Medium, "Measure project state.", actorId, actorId, now));
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
}
|
||||
await db.Database.ExecuteSqlRawAsync("ANALYZE pages; ANALYZE projects;");
|
||||
|
||||
var repository = new UnifiedSearchRepository(db);
|
||||
var first = await repository.SearchAsync(workspaceId, "measure", null, null, null, 50, CancellationToken.None);
|
||||
Assert.Equal(10_000, first.Counts.Knowledge);
|
||||
Assert.Equal(2_000, first.Counts.Projects);
|
||||
Assert.Equal(50, first.Items.Count);
|
||||
Assert.NotNull(first.NextCursor);
|
||||
var next = await repository.SearchAsync(workspaceId, "measure", null, null, first.NextCursor, 50, CancellationToken.None);
|
||||
Assert.Equal(first.Counts, next.Counts);
|
||||
Assert.Equal(50, next.Items.Count);
|
||||
Assert.Empty(first.Items.Select(item => item.Id).Intersect(next.Items.Select(item => item.Id)));
|
||||
|
||||
var report = new StringBuilder();
|
||||
report.AppendLine(CultureInfo.InvariantCulture, $"Unified search scale — {DateTimeOffset.UtcNow:O}; 10000 pages, 2000 projects; 30 warm samples per query.");
|
||||
foreach (var query in new[] { "measure", "uniqueneedle" })
|
||||
{
|
||||
var samples = new List<double>();
|
||||
for (var sample = 0; sample < 30; sample++)
|
||||
{
|
||||
var watch = Stopwatch.StartNew();
|
||||
var result = await repository.SearchAsync(workspaceId, query, null, null, null, 50, CancellationToken.None);
|
||||
watch.Stop();
|
||||
Assert.Equal(query == "measure" ? 10_000 : 1, result.Counts.Knowledge);
|
||||
Assert.Equal(query == "measure" ? 2_000 : 1, result.Counts.Projects);
|
||||
Assert.InRange(result.Items.Count, 1, 50);
|
||||
samples.Add(watch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
samples.Sort();
|
||||
var p95 = samples[28];
|
||||
report.AppendLine(CultureInfo.InvariantCulture, $"{query}: p50={samples[14]:F2}ms p95={p95:F2}ms max={samples[^1]:F2}ms");
|
||||
// Same broad correctness/latency budget as existing scale gates; this
|
||||
// exercises the new grouped-count query rather than the legacy search.
|
||||
Assert.True(p95 < 3_000, $"Unified search p95 for '{query}' was {p95:F2}ms (budget 3000ms).");
|
||||
}
|
||||
|
||||
using var response = await client.GetAsync("/api/v1/unified-search?q=measure&limit=50");
|
||||
response.EnsureSuccessStatusCode();
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||
var body = await response.Content.ReadFromJsonAsync<UnifiedSearchResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(50, body.Items.Count);
|
||||
Assert.Equal(12_000, body.Counts.Total);
|
||||
Assert.True(bytes.Length < 100_000, $"One 50-item search page contained {bytes.Length} bytes.");
|
||||
report.AppendLine(CultureInfo.InvariantCulture, $"HTTP 50-item response: {bytes.Length} bytes; total=12000; no duplicate IDs across cursor pages.");
|
||||
Directory.CreateDirectory("artifacts/performance");
|
||||
await File.WriteAllTextAsync("artifacts/performance/platform-unified-search-scale.txt", report.ToString());
|
||||
}
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using ItWorx.KnowledgeHub.Application.Identity;
|
||||
using ItWorx.KnowledgeHub.Contracts.Admin;
|
||||
using ItWorx.KnowledgeHub.Contracts.Automation;
|
||||
using ItWorx.KnowledgeHub.Contracts.UnifiedSearch;
|
||||
using ItWorx.KnowledgeHub.Domain.Automation;
|
||||
using ItWorx.KnowledgeHub.Domain.Decisions;
|
||||
using ItWorx.KnowledgeHub.Domain.Identity;
|
||||
using ItWorx.KnowledgeHub.Domain.Infrastructure;
|
||||
using ItWorx.KnowledgeHub.Domain.Knowledge;
|
||||
using ItWorx.KnowledgeHub.Domain.Projects;
|
||||
using ItWorx.KnowledgeHub.Domain.Runbooks;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
|
||||
using ItWorx.KnowledgeHub.Infrastructure.UnifiedSearch;
|
||||
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ItWorx.KnowledgeHub.IntegrationTests.UnifiedSearch;
|
||||
|
||||
[Collection(PostgresTestGroup.Name)]
|
||||
public sealed class UnifiedSearchTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Search_returns_workspace_scoped_visible_items_with_full_counts_snippets_and_global_pagination()
|
||||
{
|
||||
using var owner = await LoginOwnerAsync();
|
||||
var expected = await SeedSearchCorpusAsync();
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var workspaces = scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>();
|
||||
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(CancellationToken.None);
|
||||
Assert.NotNull(workspaceId);
|
||||
|
||||
var repository = new UnifiedSearchRepository(db);
|
||||
var firstPage = await repository.SearchAsync(workspaceId.Value, "needle", null, null, null, 2, CancellationToken.None);
|
||||
|
||||
Assert.Equal(new UnifiedSearchCountsResultExpected(1, 1, 1, 1, 1), new UnifiedSearchCountsResultExpected(
|
||||
firstPage.Counts.Knowledge, firstPage.Counts.Projects, firstPage.Counts.Services,
|
||||
firstPage.Counts.Runbooks, firstPage.Counts.Decisions));
|
||||
Assert.Equal(2, firstPage.Items.Count);
|
||||
Assert.NotNull(firstPage.NextCursor);
|
||||
|
||||
var allHits = firstPage.Items.ToList();
|
||||
var cursor = firstPage.NextCursor;
|
||||
while (cursor is not null)
|
||||
{
|
||||
var next = await repository.SearchAsync(workspaceId.Value, "needle", null, null, cursor, 2, CancellationToken.None);
|
||||
Assert.Equal(firstPage.Counts, next.Counts);
|
||||
allHits.AddRange(next.Items);
|
||||
cursor = next.NextCursor;
|
||||
}
|
||||
|
||||
Assert.Equal(5, allHits.Count);
|
||||
Assert.Equal(5, allHits.Select(item => (item.Kind, item.Id)).Distinct().Count());
|
||||
Assert.Equal(new[] { "decision", "knowledge", "project", "runbook", "service" },
|
||||
allHits.Select(item => item.Kind).Order(StringComparer.Ordinal).ToArray());
|
||||
Assert.All(allHits, item => Assert.Contains("**needle**", item.Snippet, StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(allHits, item => item.Id == expected.KnowledgePageId && item.Href == $"/knowledge/{expected.KnowledgePageId}/needle-knowledge-guide");
|
||||
Assert.Contains(allHits, item => item.Id == expected.ProjectId && item.Href == $"/projects/{expected.ProjectId}");
|
||||
Assert.Contains(allHits, item => item.Id == expected.ServiceId && item.Href == $"/infrastructure/services/{expected.ServiceId}");
|
||||
Assert.Contains(allHits, item => item.Id == expected.RunbookId && item.Href == $"/runbooks/{expected.RunbookId}");
|
||||
Assert.Contains(allHits, item => item.Id == expected.DecisionId && item.Href == $"/projects/{expected.ProjectId}/decisions/{expected.DecisionId}");
|
||||
|
||||
using var reader = await CreateReaderClientAsync(owner);
|
||||
var firstHttpPage = await reader.GetFromJsonAsync<UnifiedSearchResponse>("/api/v1/unified-search?q=needle&limit=2");
|
||||
Assert.NotNull(firstHttpPage);
|
||||
Assert.Equal(2, firstHttpPage.Items.Count);
|
||||
Assert.Equal(1, firstHttpPage.Counts.Knowledge);
|
||||
Assert.Equal(1, firstHttpPage.Counts.Projects);
|
||||
Assert.Equal(1, firstHttpPage.Counts.Services);
|
||||
Assert.Equal(1, firstHttpPage.Counts.Runbooks);
|
||||
Assert.Equal(1, firstHttpPage.Counts.Decisions);
|
||||
Assert.NotNull(firstHttpPage.NextCursor);
|
||||
|
||||
var serviceHttpPage = await reader.GetFromJsonAsync<UnifiedSearchResponse>("/api/v1/unified-search?q=needle&kind=services&limit=2");
|
||||
Assert.NotNull(serviceHttpPage);
|
||||
Assert.Single(serviceHttpPage.Items);
|
||||
Assert.Equal("services", serviceHttpPage.Items[0].Kind);
|
||||
Assert.Equal(5, serviceHttpPage.Counts.Total);
|
||||
using var invalidKind = await reader.GetAsync("/api/v1/unified-search?q=needle&kind=service");
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, invalidKind.StatusCode);
|
||||
|
||||
Assert.DoesNotContain(allHits, item => item.Title.Contains("hidden", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(allHits, item => item.Title.Contains("archived", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(allHits, item => item.Title.Contains("proposed", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(allHits, item => item.Title.Contains("trashed", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(allHits, item => item.Title.Contains("foreign", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(allHits, item => item.Snippet.Contains("hidden secret", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tag_only_search_filters_visible_page_kinds_and_binds_cursors_to_the_normalized_tag()
|
||||
{
|
||||
using var owner = await LoginOwnerAsync();
|
||||
var expected = await SeedSearchCorpusAsync();
|
||||
var decoyPageId = await AddTagFilterDecoyAsync(expected.WorkspaceId);
|
||||
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var repository = new UnifiedSearchRepository(db);
|
||||
var firstPage = await repository.SearchAsync(
|
||||
expected.WorkspaceId, string.Empty, " TOPIC ", null, null, 1, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, firstPage.Counts.Knowledge);
|
||||
Assert.Equal(0, firstPage.Counts.Projects);
|
||||
Assert.Equal(0, firstPage.Counts.Services);
|
||||
Assert.Equal(1, firstPage.Counts.Runbooks);
|
||||
Assert.Equal(1, firstPage.Counts.Decisions);
|
||||
Assert.Single(firstPage.Items);
|
||||
Assert.Equal(expected.DecisionId, firstPage.Items[0].Id);
|
||||
Assert.NotNull(firstPage.NextCursor);
|
||||
|
||||
// Casing and surrounding whitespace normalize to the same tag, so this cursor
|
||||
// continues the original page instead of repeating its first row.
|
||||
var nextPage = await repository.SearchAsync(
|
||||
expected.WorkspaceId, string.Empty, "topic", null, firstPage.NextCursor, 2, CancellationToken.None);
|
||||
Assert.Equal(2, nextPage.Items.Count);
|
||||
Assert.Null(nextPage.NextCursor);
|
||||
Assert.DoesNotContain(nextPage.Items, item => item.Id == firstPage.Items[0].Id);
|
||||
Assert.Contains(nextPage.Items, item => item.Id == expected.KnowledgePageId);
|
||||
Assert.Contains(nextPage.Items, item => item.Id == expected.RunbookId);
|
||||
Assert.Equal(firstPage.Counts, nextPage.Counts);
|
||||
|
||||
// A cursor from the broader topic result must not skip the only item in a
|
||||
// different tag result, even when that item is the same decision page.
|
||||
var differentTag = await repository.SearchAsync(
|
||||
expected.WorkspaceId, string.Empty, "DECISION-ONLY", null, firstPage.NextCursor, 2, CancellationToken.None);
|
||||
Assert.Single(differentTag.Items);
|
||||
Assert.Equal(expected.DecisionId, differentTag.Items[0].Id);
|
||||
Assert.Equal(0, differentTag.Counts.Knowledge);
|
||||
Assert.Equal(0, differentTag.Counts.Projects);
|
||||
Assert.Equal(0, differentTag.Counts.Services);
|
||||
Assert.Equal(0, differentTag.Counts.Runbooks);
|
||||
Assert.Equal(1, differentTag.Counts.Decisions);
|
||||
|
||||
var exactTagWithText = await repository.SearchAsync(
|
||||
expected.WorkspaceId, "needle", "TOPIC", null, null, 10, CancellationToken.None);
|
||||
Assert.Equal(1, exactTagWithText.Counts.Knowledge);
|
||||
Assert.Equal(0, exactTagWithText.Counts.Projects);
|
||||
Assert.Equal(0, exactTagWithText.Counts.Services);
|
||||
Assert.Equal(1, exactTagWithText.Counts.Runbooks);
|
||||
Assert.Equal(1, exactTagWithText.Counts.Decisions);
|
||||
Assert.DoesNotContain(exactTagWithText.Items, item => item.Id == decoyPageId);
|
||||
|
||||
using var reader = await CreateReaderClientAsync(owner);
|
||||
var tagOnlyHttp = await reader.GetFromJsonAsync<UnifiedSearchResponse>("/api/v1/unified-search?tag=TOPIC&limit=10");
|
||||
Assert.NotNull(tagOnlyHttp);
|
||||
Assert.Equal(3, tagOnlyHttp.Items.Count);
|
||||
Assert.Equal(1, tagOnlyHttp.Counts.Knowledge);
|
||||
Assert.Equal(0, tagOnlyHttp.Counts.Projects);
|
||||
Assert.Equal(0, tagOnlyHttp.Counts.Services);
|
||||
Assert.Equal(1, tagOnlyHttp.Counts.Runbooks);
|
||||
Assert.Equal(1, tagOnlyHttp.Counts.Decisions);
|
||||
|
||||
var textAndTagHttp = await reader.GetFromJsonAsync<UnifiedSearchResponse>("/api/v1/unified-search?q=needle&tag=TOPIC&limit=10");
|
||||
Assert.NotNull(textAndTagHttp);
|
||||
Assert.Equal(3, textAndTagHttp.Items.Count);
|
||||
Assert.DoesNotContain(textAndTagHttp.Items, item => item.Id == decoyPageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unified_search_requires_a_cookie_reader_and_does_not_grant_unified_access_to_knowledge_search_tokens()
|
||||
{
|
||||
using var owner = await LoginOwnerAsync();
|
||||
using var reader = await CreateReaderClientAsync(owner);
|
||||
|
||||
using var readerResponse = await reader.GetAsync("/api/v1/unified-search?q=scope-probe");
|
||||
Assert.Equal(HttpStatusCode.OK, readerResponse.StatusCode);
|
||||
|
||||
var tokenResponse = await SetupHelper.PostWithCsrfAsync(
|
||||
owner,
|
||||
"/api/v1/service-accounts",
|
||||
new CreateServiceAccountRequest($"unified-search-{Guid.NewGuid():N}", "Search authorization test.", [Scopes.KnowledgeSearch]));
|
||||
Assert.Equal(HttpStatusCode.OK, tokenResponse.StatusCode);
|
||||
var token = await tokenResponse.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
|
||||
Assert.NotNull(token);
|
||||
|
||||
using var bearer = Factory.CreateClient();
|
||||
bearer.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.RawToken);
|
||||
using var existingKnowledgeSearch = await bearer.GetAsync("/api/v1/search?q=scope-probe");
|
||||
Assert.Equal(HttpStatusCode.OK, existingKnowledgeSearch.StatusCode);
|
||||
|
||||
using var unifiedSearch = await bearer.GetAsync("/api/v1/unified-search?q=scope-probe");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unifiedSearch.StatusCode);
|
||||
|
||||
using var anonymous = Factory.CreateClient();
|
||||
using var anonymousResponse = await anonymous.GetAsync("/api/v1/unified-search?q=scope-probe");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymousResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unified_search_rate_limits_a_reader_after_sixty_requests_per_minute()
|
||||
{
|
||||
using var owner = await LoginOwnerAsync();
|
||||
using var reader = await CreateReaderClientAsync(owner);
|
||||
|
||||
for (var requestNumber = 1; requestNumber <= 61; requestNumber++)
|
||||
{
|
||||
using var response = await reader.GetAsync("/api/v1/unified-search?q=");
|
||||
var expected = requestNumber <= 60 ? HttpStatusCode.OK : HttpStatusCode.TooManyRequests;
|
||||
Assert.Equal(expected, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpClient> LoginOwnerAsync()
|
||||
{
|
||||
var owner = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(owner, OwnerEmail, OwnerPassword);
|
||||
return owner;
|
||||
}
|
||||
|
||||
private async Task<HttpClient> CreateReaderClientAsync(HttpClient owner)
|
||||
{
|
||||
var email = $"reader-{Guid.NewGuid():N}@itworx.test";
|
||||
var created = await SetupHelper.PostWithCsrfAsync(
|
||||
owner,
|
||||
"/api/v1/admin/users",
|
||||
new CreateUserRequest("Unified search Reader", email, "reader-strong-password", "Reader"));
|
||||
Assert.Equal(HttpStatusCode.OK, created.StatusCode);
|
||||
|
||||
var reader = Factory.CreateClient();
|
||||
await SetupHelper.LoginAsync(reader, email, "reader-strong-password");
|
||||
return reader;
|
||||
}
|
||||
|
||||
private async Task<SearchCorpusIds> SeedSearchCorpusAsync()
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var workspaceId = await scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>()
|
||||
.GetDefaultWorkspaceIdAsync(CancellationToken.None);
|
||||
Assert.NotNull(workspaceId);
|
||||
var actorId = await db.Users.AsNoTracking()
|
||||
.Where(user => user.Email == OwnerEmail)
|
||||
.Select(user => user.Id)
|
||||
.SingleAsync();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var project = Project.Create(workspaceId.Value, "Needle project", "Project summary for needle search.",
|
||||
ProjectPriority.Medium, "Project state needle.", actorId, actorId, now);
|
||||
|
||||
var knowledgePage = CreatePage(workspaceId.Value, actorId, now, "Needle knowledge guide", DocumentType.WikiPage,
|
||||
"Knowledge body with needle details.");
|
||||
var trashedPage = CreatePage(workspaceId.Value, actorId, now, "Needle trashed entry", DocumentType.WikiPage,
|
||||
"This trashed record must not appear.");
|
||||
trashedPage.Trash(actorId, now, TimeSpan.FromDays(30));
|
||||
|
||||
var runbookPage = CreatePage(workspaceId.Value, actorId, now, "Needle runbook procedure", DocumentType.Runbook,
|
||||
"Runbook body with needle procedure details.");
|
||||
var runbook = RunbookProfile.Create(runbookPage.Id, actorId, RunbookRiskLevel.Low, false, 20, null, now);
|
||||
|
||||
var decisionPage = CreatePage(workspaceId.Value, actorId, now, "Needle decision record", DocumentType.ArchitectureDecision,
|
||||
"Decision body with needle context.");
|
||||
knowledgePage.ReplaceTags(["Topic"]);
|
||||
trashedPage.ReplaceTags(["TOPIC"]);
|
||||
runbookPage.ReplaceTags(["topic"]);
|
||||
decisionPage.ReplaceTags(["tOpIc", "Decision-Only"]);
|
||||
var decision = DecisionRecord.Create(workspaceId.Value, project.Id, decisionPage.Id, 1,
|
||||
DateOnly.FromDateTime(now.UtcDateTime), "Decision summary for needle search.", null, actorId, now);
|
||||
|
||||
var foreignWorkspace = Workspace.Create(WorkspaceName.Create("Second search workspace"), now);
|
||||
var foreignPage = CreatePage(foreignWorkspace.Id, actorId, now, "Needle foreign page", DocumentType.WikiPage,
|
||||
"Foreign workspace content with needle.");
|
||||
foreignPage.ReplaceTags(["Topic"]);
|
||||
|
||||
var snapshot = InventorySnapshot.Create(workspaceId.Value, $"unified-search-{Guid.NewGuid():N}",
|
||||
Guid.NewGuid().ToString("N"), now, now, 0, 4);
|
||||
var visibleService = CreateService(workspaceId.Value, snapshot.Id, "needle-service-visible", "Needle visible service", actorId, now, reviewed: true);
|
||||
var hiddenService = CreateService(workspaceId.Value, snapshot.Id, "needle-service-hidden", "Needle hidden service", actorId, now, reviewed: true);
|
||||
var archivedService = CreateService(workspaceId.Value, snapshot.Id, "needle-service-archived", "Needle archived service", actorId, now, reviewed: true);
|
||||
var proposedService = CreateService(workspaceId.Value, snapshot.Id, "needle-service-proposed", "Needle proposed service", actorId, now, reviewed: false);
|
||||
var visibleProfile = CreateServiceProfile(workspaceId.Value, visibleService.Id, actorId, now, isHidden: false, isArchived: false);
|
||||
var hiddenProfile = CreateServiceProfile(workspaceId.Value, hiddenService.Id, actorId, now, isHidden: true, isArchived: false);
|
||||
var archivedProfile = CreateServiceProfile(workspaceId.Value, archivedService.Id, actorId, now, isHidden: false, isArchived: true);
|
||||
|
||||
db.Workspaces.Add(foreignWorkspace);
|
||||
db.Projects.Add(project);
|
||||
db.Pages.AddRange(knowledgePage, trashedPage, runbookPage, decisionPage, foreignPage);
|
||||
db.RunbookProfiles.Add(runbook);
|
||||
db.DecisionRecords.Add(decision);
|
||||
db.InventorySnapshots.Add(snapshot);
|
||||
db.InfrastructureServices.AddRange(visibleService, hiddenService, archivedService, proposedService);
|
||||
db.EditorialProfiles.AddRange(visibleProfile, hiddenProfile, archivedProfile);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return new SearchCorpusIds(workspaceId.Value, knowledgePage.Id, project.Id, visibleService.Id, runbook.Id, decision.Id);
|
||||
}
|
||||
|
||||
private async Task<Guid> AddTagFilterDecoyAsync(Guid workspaceId)
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
|
||||
var actorId = await db.Users.AsNoTracking()
|
||||
.Where(user => user.Email == OwnerEmail)
|
||||
.Select(user => user.Id)
|
||||
.SingleAsync();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var decoy = CreatePage(workspaceId, actorId, now, "Needle tag decoy", DocumentType.WikiPage,
|
||||
"A needle page tagged with a longer, non-matching label.");
|
||||
decoy.ReplaceTags(["topic-extra"]);
|
||||
db.Pages.Add(decoy);
|
||||
await db.SaveChangesAsync();
|
||||
return decoy.Id;
|
||||
}
|
||||
|
||||
private static Page CreatePage(Guid workspaceId, Guid actorId, DateTimeOffset now, string title, DocumentType type, string markdown) =>
|
||||
Page.Create(workspaceId, null, SortKey.First(), PageTitle.Create(title), type, DocumentStatus.Active, markdown, actorId, now);
|
||||
|
||||
private static InfrastructureService CreateService(
|
||||
Guid workspaceId, Guid snapshotId, string sourceKey, string name, Guid actorId, DateTimeOffset now, bool reviewed)
|
||||
{
|
||||
var service = InfrastructureService.Create(workspaceId, sourceKey, name, now, snapshotId);
|
||||
service.Refresh(null, name, "container", "running", HealthState.Healthy, "example/needle:latest", null, string.Empty,
|
||||
false, false, false, FactConfidence.High, snapshotId, now);
|
||||
if (reviewed)
|
||||
{
|
||||
service.Confirm();
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
private static EditorialProfile CreateServiceProfile(
|
||||
Guid workspaceId, Guid serviceId, Guid actorId, DateTimeOffset now, bool isHidden, bool isArchived)
|
||||
{
|
||||
var profile = EditorialProfile.Create(workspaceId, InfrastructureObjectKind.Service, serviceId, actorId, now);
|
||||
profile.Update("Needle service profile", "Editorial needle summary.", "Infrastructure", "Purpose with needle context.",
|
||||
null, null, null, null, null, null, null, null, null, null, ["needle"], false, isHidden, isArchived, actorId, now);
|
||||
return profile;
|
||||
}
|
||||
|
||||
private sealed record SearchCorpusIds(Guid WorkspaceId, Guid KnowledgePageId, Guid ProjectId, Guid ServiceId, Guid RunbookId, Guid DecisionId);
|
||||
|
||||
private sealed record UnifiedSearchCountsResultExpected(int Knowledge, int Projects, int Services, int Runbooks, int Decisions);
|
||||
}
|
||||
Reference in new issue
Block a user