Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

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

No files matched your search

@@ -0,0 +1,249 @@
using System.Net;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Errors;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.Domain.Identity;
using ItWorx.KnowledgeHub.Domain.Projects;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
/// <summary>
/// Server-side validation for infrastructure editorial profiles
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H2): invalid
/// input must always come back as a specific RFC 7807 422 with a Dutch, actionable
/// message — never a database exception, never a silent 500.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class EditorialProfileValidationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private async Task<(HttpClient Client, Guid ServiceId)> AuthenticatedClientWithServiceAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", MinimalSnapshot($"hash-{Guid.NewGuid():N}"));
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true");
var service = Assert.Single(services!.Items);
return (client, service.Id);
}
private static UpdateEditorialProfileRequest ValidRequest(
string? displayName = "Naam", string? summary = "Samenvatting", string? category = "Other",
string? repositoryUrl = null, string? liveUrl = null, string? backupStatus = null, string? monitoringUrl = null,
Guid? ownerId = null, Guid? projectId = null, IReadOnlyList<string>? tags = null) =>
new(displayName, summary, category, "Doel", ownerId, projectId, repositoryUrl, liveUrl, backupStatus,
monitoringUrl, null, null, null, null, tags ?? [], false, false, false);
[Fact]
public async Task A_display_name_over_the_maximum_length_is_rejected_as_422_not_a_database_error()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var tooLong = new string('a', 301);
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(displayName: tooLong), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.Equal(ProblemTypes.Validation, problem!.Type);
}
[Theory]
[InlineData("javascript:alert(1)")]
[InlineData("data:text/html,<script>alert(1)</script>")]
[InlineData("file:///etc/passwd")]
[InlineData("ftp://example.test/repo")]
[InlineData("not a url")]
public async Task An_unsafe_or_malformed_repository_url_scheme_is_rejected(string unsafeUrl)
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(repositoryUrl: unsafeUrl), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_plain_http_and_https_repository_url_is_accepted()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(repositoryUrl: "https://example.test/repo", liveUrl: "http://192.0.2.10:1235"), "0");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task An_unknown_category_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(category: "NietBestaandeCategorie"), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_unknown_backup_status_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(backupStatus: "OngeldigeStatus"), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Theory]
[InlineData("Unknown")]
[InlineData("Manual")]
[InlineData("Automated")]
[InlineData("AutomatedTested")]
[InlineData("None")]
public async Task Every_allowed_backup_status_value_is_accepted(string backupStatus)
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(backupStatus: backupStatus), "0");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task More_than_the_maximum_number_of_tags_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var tooManyTags = Enumerable.Range(0, 26).Select(i => $"label-{i}").ToArray();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile", ValidRequest(tags: tooManyTags), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task Duplicate_tags_are_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(tags: ["productie", "Productie"]), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_owner_id_that_is_not_a_workspace_member_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(ownerId: Guid.NewGuid()), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_owner_id_that_is_a_real_workspace_member_is_accepted()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var createUser = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/users", new CreateUserRequest("Eigenaar Kandidaat", "eigenaar-kandidaat@itworx.test", "owner-strong-password", "Editor"));
Assert.Equal(HttpStatusCode.OK, createUser.StatusCode);
var user = await createUser.Content.ReadFromJsonAsync<UserSummaryResponse>();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(ownerId: user!.Id), "0");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task A_project_id_that_does_not_exist_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(projectId: Guid.NewGuid()), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_project_id_belonging_to_a_different_workspace_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
// Cross-workspace linkage is not reachable through this single-workspace-per-
// installation product's ordinary HTTP surface (there is no "create a second
// workspace" endpoint) — seeded directly, matching this test suite's
// established pattern for otherwise-unreachable setup states.
Guid foreignProjectId;
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var foreignWorkspace = Workspace.Create(WorkspaceName.Create("Andere workspace"), DateTimeOffset.UtcNow);
db.Workspaces.Add(foreignWorkspace);
var project = Project.Create(
foreignWorkspace.Id, "Project in andere workspace", "S.", ProjectPriority.Medium, "Start.",
Guid.NewGuid(), Guid.NewGuid(), DateTimeOffset.UtcNow);
db.Projects.Add(project);
await db.SaveChangesAsync(CancellationToken.None);
foreignProjectId = project.Id;
}
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(projectId: foreignProjectId), "0");
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_project_id_belonging_to_this_workspace_is_accepted()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var createUser = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/admin/users", new CreateUserRequest("Projecteigenaar", "project-eigenaar@itworx.test", "owner-strong-password", "Editor"));
var user = await createUser.Content.ReadFromJsonAsync<UserSummaryResponse>();
var createProject = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects", new CreateProjectRequest("Gekoppeld project", "S.", "Medium", "Start.", user!.Id));
Assert.Equal(HttpStatusCode.OK, createProject.StatusCode);
var project = await createProject.Content.ReadFromJsonAsync<ProjectDetail>();
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
ValidRequest(projectId: project!.Id), "0");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
private static SynchronizeInventoryRequest MinimalSnapshot(string hash) =>
new(
$"inventory-{hash}", hash, DateTimeOffset.UtcNow.AddMinutes(-1),
[new InventoryHostRequest("host:unraid", "Tower", "Unraid", "6.12", "x86_64", 16, 64L * 1024 * 1024 * 1024)],
[new InventoryServiceRequest(
"docker:knowledge-hub", "host:unraid", "itworx-knowledge-hub", "container", "running",
"Healthy", "itworx/knowledge-hub:v2", "sha256:example", "10001:10001", true, true, false, "High",
[], [], [], [], [], [], [], [], [])]);
}
@@ -0,0 +1,174 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
/// <summary>
/// V2-R5 (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 6): the manual-
/// override mechanism was already fully generic at the use-case/repository layer
/// (<c>SetManualOverrideUseCase</c>, <c>IInfrastructureRepository.TargetExistsAsync</c>)
/// — only the field catalog (previously an explicit, deliberately empty stub for
/// every kind but Service) and a reachable endpoint were missing. Proves the field
/// catalog is now real for every non-Service kind the domain models, and that the new
/// generic <c>POST /infrastructure/overrides</c> endpoint actually works end to end
/// for several of them.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class ExpandedManualOverrideCatalogTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Theory]
[InlineData("Host", "hostname")]
[InlineData("Component", "name")]
[InlineData("Endpoint", "scheme")]
[InlineData("Network", "name")]
[InlineData("Volume", "name")]
[InlineData("Database", "name")]
[InlineData("Deployment", "repositoryUrl")]
[InlineData("Backup", "method")]
public async Task Every_non_service_object_kind_now_has_a_real_non_empty_override_catalog(string targetKind, string expectedField)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var catalog = await client.GetFromJsonAsync<ManualOverrideFieldCatalogResponse>(
$"/api/v1/infrastructure/overrides/catalog?targetKind={targetKind}");
Assert.NotNull(catalog);
Assert.NotEmpty(catalog.Fields);
Assert.Contains(catalog.Fields, field => field.FieldName == expectedField);
}
[Fact]
public async Task A_host_fact_can_be_corrected_through_the_generic_endpoint()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (hostId, _, _, _, _, _, _, _) = await SeedFullServiceGraphAsync(client);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest("Host", hostId, "hostname", JsonSerializer.SerializeToElement("gecorrigeerde-hostnaam"), "Reden."));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Theory]
[InlineData("Network", "internal")]
[InlineData("Volume", "readOnly")]
public async Task A_boolean_shaped_fact_is_overridable_as_a_true_false_enum(string targetKind, string fieldName)
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (_, _, networkId, volumeId, _, _, _, _) = await SeedFullServiceGraphAsync(client);
var targetId = targetKind == "Network" ? networkId : volumeId;
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest(targetKind, targetId, fieldName, JsonSerializer.SerializeToElement("true"), "Reden."));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var invalid = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest(targetKind, targetId, fieldName, JsonSerializer.SerializeToElement("misschien"), "Ongeldig."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, invalid.StatusCode);
}
[Fact]
public async Task An_unknown_target_kind_string_is_rejected_not_a_crash()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (hostId, _, _, _, _, _, _, _) = await SeedFullServiceGraphAsync(client);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest("NietBestaandType", hostId, "naam", JsonSerializer.SerializeToElement("x"), null));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_target_id_that_does_not_exist_for_the_given_kind_is_not_found()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SeedFullServiceGraphAsync(client);
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest("Host", Guid.NewGuid(), "hostname", JsonSerializer.SerializeToElement("x"), null));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task An_override_set_through_the_generic_endpoint_can_be_removed_through_the_existing_generic_delete()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (hostId, _, _, _, _, _, _, _) = await SeedFullServiceGraphAsync(client);
var setResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/overrides",
new SetInfrastructureOverrideRequest("Host", hostId, "operatingSystem", JsonSerializer.SerializeToElement("Custom OS"), "Reden."));
Assert.Equal(HttpStatusCode.OK, setResponse.StatusCode);
var created = await setResponse.Content.ReadFromJsonAsync<JsonElement>();
var overrideId = created.GetProperty("id").GetGuid();
var removeResponse = await SetupHelper.DeleteWithCsrfAsync(client, $"/api/v1/infrastructure/overrides/{overrideId}");
Assert.Equal(HttpStatusCode.NoContent, removeResponse.StatusCode);
}
/// <summary>Seeds one service with one of every nested fact kind via a single real
/// inventory sync, then resolves each fact's real generated ID through the normal
/// service-detail endpoint — never a raw SQL insert, so this also incidentally
/// re-proves the sync path itself creates every fact kind correctly.</summary>
private static async Task<(Guid HostId, Guid ServiceId, Guid NetworkId, Guid VolumeId, Guid DatabaseId, Guid EndpointId, Guid DeploymentId, Guid BackupId)>
SeedFullServiceGraphAsync(HttpClient client)
{
var hash = $"hash-{Guid.NewGuid():N}";
var request = new SynchronizeInventoryRequest(
$"inventory-{hash}", hash, DateTimeOffset.UtcNow.AddMinutes(-1),
[new InventoryHostRequest("host:unraid", "Tower", "Unraid", "6.12", "x86_64", 16, 64L * 1024 * 1024 * 1024)],
[new InventoryServiceRequest(
"docker:knowledge-hub", "host:unraid", "itworx-knowledge-hub", "container", "running",
"Healthy", "itworx/knowledge-hub:v2", "sha256:example", "10001:10001", true, true, false, "High",
[new InventoryComponentRequest("component:app", "app", "container", "rt-1", "running", "itworx/app:v2", "High")],
[new InventoryEndpointRequest("endpoint:main", "https", "wiki.example.com", 1235, 8080, "https://wiki.example.com", true, "High")],
[new InventoryNetworkRequest("network:main", "knowledge-net", "bridge", "local", false, "High")],
[new InventoryVolumeRequest("volume:data", "knowledge-data", "/data", "local", false, 1024, "High")],
[new InventoryDatabaseRequest("database:main", "knowledge_hub", "postgresql", "18", "db", 5432, false, "High")],
[],
[],
[new InventoryDeploymentRequest("deployment:main", "https://gitea.example.com/itworx/knowledge-hub", "abc123", "itworx/knowledge-hub:v2", "sha256:example", DateTimeOffset.UtcNow, "High")],
[new InventoryBackupRequest("backup:main", "pg_dump", "daily", DateTimeOffset.UtcNow.AddDays(-1), true, "High")])]);
var syncResponse = await SetupHelper.PostWithCsrfAsync(client, "/api/v1/infrastructure/inventory/snapshots", request);
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
var overview = await client.GetFromJsonAsync<InfrastructureOverviewResponse>("/api/v1/infrastructure/overview");
var hostId = Assert.Single(overview!.Hosts).Id;
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>("/api/v1/infrastructure/services?includeHidden=true");
var serviceId = Assert.Single(services!.Items).Id;
var detail = await client.GetFromJsonAsync<InfrastructureServiceDetail>($"/api/v1/infrastructure/services/{serviceId}");
Assert.NotNull(detail);
return (
hostId,
serviceId,
Assert.Single(detail.Networks).Id,
Assert.Single(detail.Volumes).Id,
Assert.Single(detail.Databases).Id,
Assert.Single(detail.Endpoints).Id,
Assert.Single(detail.Deployments).Id,
Assert.Single(detail.Backups).Id);
}
}
@@ -0,0 +1,258 @@
using System.Diagnostics;
using System.Globalization;
using System.Net.Http.Json;
using System.Text;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
/// <summary>
/// The full-scale proof for infrastructure tables that
/// <c>docs/v2/V2_ACCEPTANCE_MATRIX.md</c>'s "Search/query-plan proof at scale extended
/// to infrastructure tables" row (previously `Planned`) asked for — the infrastructure
/// equivalent of <c>SearchFullScaleTests</c> (V1 M8), closing V2-R4's open release
/// risk 4 with real evidence rather than only the lighter-weight, routine-suite
/// <c>InfrastructureCatalogScaleTests</c> (V2-R2, 3,000 services). Tagged
/// "ReleaseScale" and excluded from the routine test run (matching
/// <c>SearchFullScaleTests</c>'s own precedent) — seeding and measuring at this scale
/// is a deliberate, occasional release-gate exercise. Run explicitly via:
/// <c>dotnet test --filter "Category=ReleaseScale"</c>.
/// </summary>
[Collection(PostgresTestGroup.Name)]
[Trait("Category", "ReleaseScale")]
public sealed class InfrastructureCatalogReleaseScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int ServiceCount = 50_000;
private const int SampleCount = 30;
[Fact]
public async Task Infrastructure_catalog_reads_stay_correct_and_bounded_at_50000_services()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var report = new StringBuilder();
report.AppendLine(CultureInfo.InvariantCulture, $"V2-R4 infrastructure-catalog release-scale report — {DateTimeOffset.UtcNow:O}");
report.AppendLine(CultureInfo.InvariantCulture, $"Target: {ServiceCount:N0} services (docs/v2/V2_ACCEPTANCE_MATRIX.md, previously 'Planned').");
Guid workspaceId;
var seedStopwatch = Stopwatch.StartNew();
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
workspaceId = workspace.Id;
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
await SeedServicesAsync(connection, workspaceId);
await SeedFactsAsync(connection, workspaceId);
await PrepareReadMeasurementAsync(connection);
}
seedStopwatch.Stop();
report.AppendLine(CultureInfo.InvariantCulture, $"Seed time: {seedStopwatch.Elapsed}.");
using (var scope = Factory.Services.CreateScope())
{
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
await using var countCommand = new NpgsqlCommand(
"SELECT (SELECT count(*) FROM infrastructure_services), (SELECT count(*) FROM service_components)",
connection)
{
CommandTimeout = 300
};
await using var reader = await countCommand.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync(), "Release-scale row-count query returned no row.");
var actualServices = reader.GetInt64(0);
var actualComponents = reader.GetInt64(1);
report.AppendLine(CultureInfo.InvariantCulture, $"Actual seeded rows: infrastructure_services={actualServices:N0}, service_components={actualComponents:N0}.");
Assert.True(actualServices >= ServiceCount, $"Expected at least {ServiceCount:N0} services, found {actualServices:N0}.");
}
// --- correctness: bounded, correctly-cursored pagination still holds at this scale ---
var firstPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>("/api/v1/infrastructure/services?limit=50");
Assert.NotNull(firstPage);
Assert.Equal(50, firstPage.Items.Count);
Assert.False(string.IsNullOrEmpty(firstPage.NextCursor));
var secondPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
$"/api/v1/infrastructure/services?limit=50&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
Assert.NotNull(secondPage);
Assert.Equal(50, secondPage.Items.Count);
Assert.Empty(firstPage.Items.Select(item => item.Id).Intersect(secondPage.Items.Select(item => item.Id)));
report.AppendLine("Correctness: bounded first/second page, disjoint, correct size. PASS.");
// --- latency: p50/p95/p99 for the catalog's first page and a deep-cursor page ---
report.AppendLine();
report.AppendLine("Latency (ms), 30 samples per query shape:");
var measurements = new[]
{
await MeasureAsync(client, report, "first page (limit=50)", "/api/v1/infrastructure/services?limit=50"),
await MeasureAsync(client, report, "deep cursor page (after page 1)", $"/api/v1/infrastructure/services?limit=50&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}")
};
// --- query plan + index sizes ---
using (var scope = Factory.Services.CreateScope())
{
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
report.AppendLine();
report.AppendLine("Query plan (EXPLAIN ANALYZE) for the catalog's first-page query:");
report.AppendLine(await ExplainCatalogPageAsync(connection, workspaceId));
report.AppendLine();
report.AppendLine("Index sizes:");
report.AppendLine(await IndexSizesAsync(connection, "infrastructure_services"));
}
var reportPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "artifacts", "v2-r4-infrastructure-scale-report.txt");
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
await File.WriteAllTextAsync(reportPath, report.ToString());
foreach (var measurement in measurements)
{
// Generous release-gate bound, matching SearchFullScaleTests' own — proving
// the paginated, counts-only query stays sub-second at true release scale,
// not a tight micro-benchmark.
Assert.True(
measurement.P95 < 1000,
$"{measurement.Label}: p95 was {measurement.P95:F1}ms, expected well under 1000ms at {ServiceCount:N0}-service scale.");
}
}
private static async Task SeedServicesAsync(NpgsqlConnection connection, Guid workspaceId)
{
const string sql = """
INSERT INTO infrastructure_services (
id, workspace_id, host_id, source_key, detected_name, runtime_kind, runtime_status,
health, image_reference, image_digest, runtime_user, read_only_root_filesystem,
no_new_privileges, privileged, presence, confidence, last_snapshot_id, first_seen_at, last_seen_at)
SELECT
gen_random_uuid(), @workspaceId, NULL,
'docker:release-scale-service-' || n, 'release-scale-service-' || lpad(n::text, 6, '0'),
'container', 'running', 1, 'itworx/release-scale:' || n, '', '', false, false, false,
1, 2, gen_random_uuid(), now(), now()
FROM generate_series(1, @serviceCount) AS n
""";
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 300 };
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("serviceCount", ServiceCount);
await command.ExecuteNonQueryAsync();
}
private static async Task SeedFactsAsync(NpgsqlConnection connection, Guid workspaceId)
{
const string sql = """
INSERT INTO service_components (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
name, kind, runtime_id, status, image_reference)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'container:' || c.n, 2, gen_random_uuid(), now(),
'component-' || c.n, 'container', 'rt-' || c.n, 'running', ''
FROM infrastructure_services s
CROSS JOIN generate_series(1, 2) AS c(n)
WHERE s.workspace_id = @workspaceId
""";
await using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 300 };
command.Parameters.AddWithValue("workspaceId", workspaceId);
await command.ExecuteNonQueryAsync();
}
private static async Task PrepareReadMeasurementAsync(NpgsqlConnection connection)
{
foreach (var statement in new[]
{
"VACUUM (ANALYZE) infrastructure_services;",
"VACUUM (ANALYZE) service_components;",
"CHECKPOINT;"
})
{
await using var command = new NpgsqlCommand(statement, connection) { CommandTimeout = 300 };
await command.ExecuteNonQueryAsync();
}
}
private static async Task<LatencyMeasurement> MeasureAsync(HttpClient client, StringBuilder report, string label, string path)
{
var samples = new List<double>(SampleCount);
for (var i = 0; i < SampleCount; i++)
{
var stopwatch = Stopwatch.StartNew();
using var response = await client.GetAsync(path);
stopwatch.Stop();
response.EnsureSuccessStatusCode();
samples.Add(stopwatch.Elapsed.TotalMilliseconds);
}
samples.Sort();
var p50 = Percentile(samples, 0.50);
var p95 = Percentile(samples, 0.95);
var p99 = Percentile(samples, 0.99);
report.AppendLine(CultureInfo.InvariantCulture, $" {label}: p50={p50:F1}ms p95={p95:F1}ms p99={p99:F1}ms (min={samples[0]:F1}ms max={samples[^1]:F1}ms)");
return new LatencyMeasurement(label, p95);
}
private static double Percentile(IReadOnlyList<double> sortedSamples, double percentile)
{
var index = (int)Math.Ceiling(percentile * sortedSamples.Count) - 1;
return sortedSamples[Math.Clamp(index, 0, sortedSamples.Count - 1)];
}
private static async Task<string> ExplainCatalogPageAsync(NpgsqlConnection connection, Guid workspaceId)
{
const string sql = """
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, detected_name
FROM infrastructure_services
WHERE workspace_id = @workspaceId
ORDER BY detected_name, id
LIMIT 51
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("workspaceId", workspaceId);
await using var reader = await command.ExecuteReaderAsync();
var lines = new StringBuilder();
while (await reader.ReadAsync())
{
lines.AppendLine(" " + reader.GetString(0));
}
return lines.ToString();
}
private static async Task<string> IndexSizesAsync(NpgsqlConnection connection, string tableName)
{
const string sql = """
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass))
FROM pg_indexes
WHERE tablename = @tableName
ORDER BY indexname
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tableName", tableName);
await using var reader = await command.ExecuteReaderAsync();
var lines = new StringBuilder();
while (await reader.ReadAsync())
{
lines.AppendLine(CultureInfo.InvariantCulture, $" {reader.GetString(0)}: {reader.GetString(1)}");
}
return lines.ToString();
}
private sealed record LatencyMeasurement(string Label, double P95);
}
@@ -0,0 +1,173 @@
using System.Diagnostics;
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
/// <summary>A large workspace's service catalog stays bounded and fast to page through
/// (V2-R2, docs/adr/ADR-026-v2-r2-infrastructure-scale-and-sync-durability.md, Finding
/// A): before this milestone, listing services always loaded all nine related fact
/// tables for every service in the workspace, unpaginated. This proves the catalog
/// endpoint now serves one bounded, correctly-cursored page regardless of how many
/// services (and related facts) exist in total — mirroring
/// <c>PageTreeScaleTests</c>'s established "bounded and fast, not a precise
/// benchmark" pattern for the knowledge tree.</summary>
[Collection(PostgresTestGroup.Name)]
public sealed class InfrastructureCatalogScaleTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int ServiceCount = 3_000;
[Fact]
public async Task Fetching_one_page_of_a_3000_service_catalog_stays_fast_and_bounded()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var workspaceId = await SeedServicesDirectlyAsync();
var stopwatch = Stopwatch.StartNew();
var firstPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?limit=50");
stopwatch.Stop();
Assert.NotNull(firstPage);
Assert.Equal(50, firstPage.Items.Count);
Assert.False(string.IsNullOrEmpty(firstPage.NextCursor));
// Every summary row must still carry the fact counts the catalog card renders,
// even though the query behind it now fetches only counts, not full related rows.
Assert.All(firstPage.Items, item =>
{
Assert.Equal(2, item.ComponentCount);
Assert.Equal(1, item.EndpointCount);
Assert.Equal(1, item.VolumeCount);
Assert.Equal(1, item.DatabaseCount);
});
// Generous bound for a CI/dev machine: the point is "bounded and fast",
// regardless of the {ServiceCount} total services and their ~5x related facts.
Assert.True(
stopwatch.Elapsed < TimeSpan.FromSeconds(5),
$"Fetching one bounded page of services took {stopwatch.Elapsed}, expected well under 5s at {ServiceCount} total services.");
var secondPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
$"/api/v1/infrastructure/services?limit=50&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
Assert.NotNull(secondPage);
Assert.Equal(50, secondPage.Items.Count);
Assert.Empty(firstPage.Items.Select(item => item.Id).Intersect(secondPage.Items.Select(item => item.Id)));
_ = workspaceId;
}
private async Task<Guid> SeedServicesDirectlyAsync()
{
// Seeded through a handful of server-side INSERT...SELECT statements (one round
// trip per table, not one per row or per fact) — the same generate_series
// pattern SearchFullScaleTests already established for bulk-seeding at scale.
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
var workspaceId = workspace.Id;
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
var snapshotId = Guid.NewGuid();
const string servicesSql = """
INSERT INTO infrastructure_services (
id, workspace_id, host_id, source_key, detected_name, runtime_kind, runtime_status,
health, image_reference, image_digest, runtime_user, read_only_root_filesystem,
no_new_privileges, privileged, presence, confidence, last_snapshot_id, first_seen_at, last_seen_at)
SELECT
gen_random_uuid(), @workspaceId, NULL,
'docker:schaal-service-' || n, 'schaal-service-' || lpad(n::text, 5, '0'),
'container', 'running', 1, 'itworx/schaal:' || n, '', '', false, false, false,
1, 2, @snapshotId, now(), now()
FROM generate_series(1, @serviceCount) AS n
""";
await using (var command = new NpgsqlCommand(servicesSql, connection) { CommandTimeout = 120 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
command.Parameters.AddWithValue("serviceCount", ServiceCount);
await command.ExecuteNonQueryAsync();
}
const string componentsSql = """
INSERT INTO service_components (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
name, kind, runtime_id, status, image_reference)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'container:' || c.n, 2, @snapshotId, now(),
'component-' || c.n, 'container', 'rt-' || c.n, 'running', ''
FROM infrastructure_services s
CROSS JOIN generate_series(1, 2) AS c(n)
WHERE s.workspace_id = @workspaceId
""";
await using (var command = new NpgsqlCommand(componentsSql, connection) { CommandTimeout = 120 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
await command.ExecuteNonQueryAsync();
}
const string endpointsSql = """
INSERT INTO infrastructure_endpoints (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
scheme, host, published_port, target_port, url, is_public)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'port:main', 2, @snapshotId, now(),
'http', '', NULL, NULL, '', false
FROM infrastructure_services s
WHERE s.workspace_id = @workspaceId
""";
await using (var command = new NpgsqlCommand(endpointsSql, connection) { CommandTimeout = 120 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
await command.ExecuteNonQueryAsync();
}
const string volumesSql = """
INSERT INTO infrastructure_volumes (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
name, mount_path, storage_class, read_only, size_bytes)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'volume:main', 2, @snapshotId, now(),
'data', '/data', '', false, NULL
FROM infrastructure_services s
WHERE s.workspace_id = @workspaceId
""";
await using (var command = new NpgsqlCommand(volumesSql, connection) { CommandTimeout = 120 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
await command.ExecuteNonQueryAsync();
}
const string databasesSql = """
INSERT INTO infrastructure_databases (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
name, engine, version, host, port, host_port_published)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'database:main', 2, @snapshotId, now(),
'main', 'postgresql', '17', '', NULL, false
FROM infrastructure_services s
WHERE s.workspace_id = @workspaceId
""";
await using (var command = new NpgsqlCommand(databasesSql, connection) { CommandTimeout = 120 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
await command.ExecuteNonQueryAsync();
}
return workspaceId;
}
}
@@ -0,0 +1,248 @@
using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Auth;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.Contracts.Projects;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
/// <summary>
/// V2-R5 (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 4): `GET
/// /infrastructure/storage` was a fully unbounded, unpaginated listing, and
/// `GET /infrastructure/services` had no server-side project filter — the
/// Project workspace fetched up to 200 services and filtered by project client-side.
/// Proves the fix: real server-side cursor pagination (no duplicates, no gaps, stable
/// across pages), a server-side text search, and a server-side project filter.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class InfrastructurePaginationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private const int VolumeCount = 130;
[Fact]
public async Task Storage_pages_through_every_volume_exactly_once_with_no_duplicates_or_gaps()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SeedVolumesDirectlyAsync();
var seenIds = new HashSet<Guid>();
string? cursor = null;
var pageCount = 0;
do
{
var url = "/api/v1/infrastructure/storage?limit=20" + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}");
var page = await client.GetFromJsonAsync<InfrastructureStorageListResponse>(url);
Assert.NotNull(page);
Assert.True(page.Items.Count <= 20);
foreach (var item in page.Items)
{
Assert.True(seenIds.Add(item.Id), $"Volume {item.Id} appeared on more than one page.");
}
cursor = page.NextCursor;
pageCount++;
Assert.True(pageCount < 50, "Too many pages — likely a pagination bug (cursor never advances / never terminates).");
}
while (cursor is not null);
Assert.Equal(VolumeCount, seenIds.Count);
Assert.True(pageCount > 1, "Expected more than one page at this seeded volume count.");
}
[Fact]
public async Task Storage_search_filters_server_side_not_only_within_the_first_loaded_page()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SeedVolumesDirectlyAsync();
// "schaal-service-00099" (a service near the end of the seeded range, unlikely
// to appear on a single default-sized first page) must still be found — proving
// the search runs server-side across the whole table, not client-side over
// whatever page happened to load first.
var result = await client.GetFromJsonAsync<InfrastructureStorageListResponse>(
"/api/v1/infrastructure/storage?search=schaal-service-00099&limit=20");
Assert.NotNull(result);
Assert.Single(result.Items);
Assert.Contains("schaal-service-00099", result.Items[0].ServiceName);
Assert.Null(result.NextCursor);
}
[Fact]
public async Task Services_search_filters_server_side_not_only_within_the_first_loaded_page()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SeedVolumesDirectlyAsync();
var result = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true&search=schaal-service-00099&limit=20");
Assert.NotNull(result);
Assert.Single(result.Items);
Assert.Contains("schaal-service-00099", result.Items[0].Name);
Assert.Null(result.NextCursor);
}
[Fact]
public async Task Services_can_be_filtered_by_project_entirely_server_side()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (workspaceId, allServiceIds) = await SeedVolumesDirectlyAsync();
var session = await client.GetFromJsonAsync<SessionResponse>("/api/v1/auth/session");
Assert.NotNull(session?.UserId);
var projectResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/projects",
new CreateProjectRequest(
$"Paginatie-project {Guid.NewGuid():N}", "Testproject voor paginatiedekking.", "Medium", "# Status", session.UserId.Value));
Assert.Equal(System.Net.HttpStatusCode.OK, projectResponse.StatusCode);
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectDetail>();
Assert.NotNull(project);
var projectId = project.Id;
// Link exactly 3 of the 130 seeded services to this project through the real
// editorial-profile endpoint — proving the filter is a real server-side WHERE
// against genuine domain state, not a client-side slice of an unfiltered page.
var linkedIds = allServiceIds.Take(3).ToArray();
foreach (var serviceId in linkedIds)
{
var response = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/editorial-profile",
new { isFavorite = false, isHidden = false, isArchived = false, projectId }, "0");
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
}
_ = workspaceId;
var filtered = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
$"/api/v1/infrastructure/services?includeHidden=true&projectId={projectId}&limit=200");
Assert.NotNull(filtered);
Assert.Equal(3, filtered.Items.Count);
Assert.Equal(linkedIds.ToHashSet(), filtered.Items.Select(item => item.Id).ToHashSet());
}
[Fact]
public async Task Services_status_filter_is_applied_before_cursor_pagination()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (_, serviceIds) = await SeedVolumesDirectlyAsync();
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
await db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE infrastructure_services SET health = {(short)ItWorx.KnowledgeHub.Domain.Infrastructure.HealthState.Unhealthy} WHERE id = {serviceIds[^1]}");
}
// The unhealthy service is deliberately the final seeded row. A server-side
// WHERE is required: a local filter over a first page could never find it.
var result = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true&status=attention&limit=20");
Assert.NotNull(result);
Assert.Single(result.Items);
Assert.Equal(serviceIds[^1], result.Items[0].Id);
}
[Fact]
public async Task Services_report_the_filtered_total_not_the_size_of_the_returned_page()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var (_, serviceIds) = await SeedVolumesDirectlyAsync();
using (var scope = Factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
await db.Database.ExecuteSqlInterpolatedAsync(
$"UPDATE infrastructure_services SET health = {(short)ItWorx.KnowledgeHub.Domain.Infrastructure.HealthState.Unhealthy} WHERE id = {serviceIds[^1]}");
}
// A deliberately small page over a deliberately large catalog: the operator
// must be told how many services exist behind the filter, not how many fitted
// on the first page.
var firstPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true&limit=10");
Assert.NotNull(firstPage);
Assert.Equal(10, firstPage.Items.Count);
Assert.NotNull(firstPage.NextCursor);
Assert.Equal(serviceIds.Count, firstPage.TotalCount);
// The total tracks the filter, and stays stable while paging through it.
var attention = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true&status=attention&limit=10");
Assert.NotNull(attention);
Assert.Equal(1, attention.TotalCount);
var secondPage = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
$"/api/v1/infrastructure/services?includeHidden=true&limit=10&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
Assert.NotNull(secondPage);
Assert.Equal(serviceIds.Count, secondPage.TotalCount);
}
private async Task<(Guid WorkspaceId, IReadOnlyList<Guid> ServiceIds)> SeedVolumesDirectlyAsync()
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var workspace = await db.Workspaces.AsNoTracking().FirstAsync();
var workspaceId = workspace.Id;
var connectionOptions = scope.ServiceProvider.GetRequiredService<DatabaseConnectionOptions>();
await using var connection = new NpgsqlConnection(connectionOptions.ConnectionString);
await connection.OpenAsync();
var snapshotId = Guid.NewGuid();
const string servicesSql = """
INSERT INTO infrastructure_services (
id, workspace_id, host_id, source_key, detected_name, runtime_kind, runtime_status,
health, image_reference, image_digest, runtime_user, read_only_root_filesystem,
no_new_privileges, privileged, presence, confidence, last_snapshot_id, first_seen_at, last_seen_at)
SELECT
gen_random_uuid(), @workspaceId, NULL,
'docker:schaal-service-' || n, 'schaal-service-' || lpad(n::text, 5, '0'),
'container', 'running', 1, 'itworx/schaal:' || n, '', '', false, false, false,
1, 2, @snapshotId, now(), now()
FROM generate_series(1, @count) AS n
RETURNING id
""";
var serviceIds = new List<Guid>();
await using (var command = new NpgsqlCommand(servicesSql, connection) { CommandTimeout = 60 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
command.Parameters.AddWithValue("count", VolumeCount);
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
serviceIds.Add(reader.GetGuid(0));
}
}
const string volumesSql = """
INSERT INTO infrastructure_volumes (
id, workspace_id, service_id, source_key, confidence, last_snapshot_id, observed_at,
name, mount_path, storage_class, read_only, size_bytes)
SELECT
gen_random_uuid(), @workspaceId, s.id, 'volume:main', 2, @snapshotId, now(),
'data', '/data', '', false, NULL
FROM infrastructure_services s
WHERE s.workspace_id = @workspaceId
""";
await using (var command = new NpgsqlCommand(volumesSql, connection) { CommandTimeout = 60 })
{
command.Parameters.AddWithValue("workspaceId", workspaceId);
command.Parameters.AddWithValue("snapshotId", snapshotId);
await command.ExecuteNonQueryAsync();
}
return (workspaceId, serviceIds);
}
}
@@ -0,0 +1,514 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using ItWorx.KnowledgeHub.Contracts.Admin;
using ItWorx.KnowledgeHub.Contracts.Automation;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.Domain.Automation;
using ItWorx.KnowledgeHub.Infrastructure.Persistence;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
[Collection(PostgresTestGroup.Name)]
public sealed class InfrastructureSynchronizationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
[Fact]
public async Task Inventory_facts_are_typed_and_editorial_knowledge_survives_later_snapshots()
{
using var client = await AuthenticatedClientAsync();
var firstSyncResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-001", includeService: true, "running"));
Assert.Equal(HttpStatusCode.OK, firstSyncResponse.StatusCode);
var firstSync = await firstSyncResponse.Content.ReadFromJsonAsync<InventorySyncResponse>();
Assert.NotNull(firstSync);
Assert.Equal(1, firstSync.AddedServices);
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true");
var service = Assert.Single(services!.Items);
var detailResponse = await client.GetAsync($"/api/v1/infrastructure/services/{service.Id}");
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
Assert.Equal("0", SetupHelper.GetETag(detailResponse));
var detail = await detailResponse.Content.ReadFromJsonAsync<InfrastructureServiceDetail>();
Assert.NotNull(detail);
Assert.Single(detail.Components);
Assert.Single(detail.Endpoints);
Assert.Single(detail.Volumes);
Assert.Single(detail.Databases);
var storage = await client.GetFromJsonAsync<InfrastructureStorageListResponse>(
"/api/v1/infrastructure/storage");
var storageRow = Assert.Single(storage!.Items);
Assert.Equal(service.Id, storageRow.ServiceId);
Assert.Equal(service.Name, storageRow.ServiceName);
Assert.Equal("/app/attachments", storageRow.MountPath);
var profileResponse = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/infrastructure/services/{service.Id}/editorial-profile",
new UpdateEditorialProfileRequest(
"ItWorx Knowledge Hub", "Centrale technische kennisomgeving.", "Knowledge", "Documentatie",
null, null, "https://example.test/repository", "http://192.0.2.10:1235", "AutomatedTested",
string.Empty, "Onderhoudsnotitie", "Capaciteitsrisico", "Pull en deploy", "Herstel vorig image",
["knowledge", "production"], true, false, false),
"0");
Assert.Equal(HttpStatusCode.OK, profileResponse.StatusCode);
var profileETag = SetupHelper.GetETag(profileResponse);
Assert.False(string.IsNullOrWhiteSpace(profileETag));
// "detectedName" is a real InfrastructureService inventory fact and therefore a
// legitimate ManualOverrideCatalog field (docs/adr/ADR-025-v2-r1-data-integrity-
// remediation.md, audit finding H3) — "category" (used here before H3) was never
// actually one: it is an EditorialProfile field with its own dedicated write
// path (the editorial-profile PUT above), and the catalog now correctly rejects
// it as an override field name.
var overrideResponse = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/infrastructure/services/{service.Id}/overrides",
new SetManualOverrideRequest(
"detectedName", JsonSerializer.Deserialize<JsonElement>("\"itworx-knowledge-hub (gecorrigeerd)\""), "Automatisch gedetecteerde naam was onduidelijk."));
Assert.Equal(HttpStatusCode.OK, overrideResponse.StatusCode);
var secondSyncResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-002", includeService: true, "healthy"));
Assert.Equal(HttpStatusCode.OK, secondSyncResponse.StatusCode);
var afterSync = await client.GetFromJsonAsync<InfrastructureServiceDetail>(
$"/api/v1/infrastructure/services/{service.Id}");
Assert.NotNull(afterSync);
Assert.Equal("ItWorx Knowledge Hub", afterSync.EditorialProfile!.DisplayName);
Assert.Equal("Centrale technische kennisomgeving.", afterSync.EditorialProfile.Summary);
Assert.Single(afterSync.Overrides);
Assert.Equal("healthy", afterSync.Service.RuntimeStatus);
var changes = await client.GetFromJsonAsync<IReadOnlyList<InventoryChangeResponse>>(
"/api/v1/infrastructure/changes?limit=100");
var runtimeChange = Assert.Single(changes!, change =>
change.TargetKind == "Service" && change.ChangeKind == "Updated"
&& change.ChangedFields.Contains("runtimeStatus"));
Assert.Equal(service.Id, runtimeChange.TargetId);
Assert.Equal("docker:knowledge-hub", runtimeChange.SourceKey);
}
[Fact]
public async Task A_disappeared_service_is_marked_missing_and_an_identical_snapshot_is_idempotent()
{
using var client = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-missing-1", includeService: true, "running"));
var missingResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-missing-2", includeService: false, "stopped"));
var missing = await missingResponse.Content.ReadFromJsonAsync<InventorySyncResponse>();
Assert.NotNull(missing);
Assert.Equal(1, missing.MissingServices);
var retryResponse = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-missing-2", includeService: false, "stopped"));
var retry = await retryResponse.Content.ReadFromJsonAsync<InventorySyncResponse>();
Assert.True(retry!.AlreadyImported);
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true");
Assert.Equal("Missing", Assert.Single(services!.Items).Presence);
}
[Fact]
public async Task Archiving_a_removed_service_removes_it_from_active_signals_and_totals_but_preserves_its_detail()
{
using var client = await AuthenticatedClientAsync();
var sync = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots",
Snapshot("hash-archive-signal", includeService: true, "stopped", health: "Unhealthy"));
Assert.Equal(HttpStatusCode.OK, sync.StatusCode);
var proposal = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
var accept = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{proposal.Id}/review",
new ReviewInfrastructureServiceRequest("Accept", "Bestaande service voor archiefregressie."));
Assert.Equal(HttpStatusCode.OK, accept.StatusCode);
var activeSignals = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?status=attention");
Assert.NotNull(activeSignals);
Assert.Equal(proposal.Id, Assert.Single(activeSignals.Items).Id);
var beforeArchive = await client.GetAsync($"/api/v1/infrastructure/services/{proposal.Id}");
Assert.Equal(HttpStatusCode.OK, beforeArchive.StatusCode);
var archive = await SetupHelper.PutWithCsrfAsync(
client,
$"/api/v1/infrastructure/services/{proposal.Id}/editorial-profile",
new UpdateEditorialProfileRequest(
"Verwijderde testcontainer", "", "Other", "", null, null, null, null, null, null,
null, null, null, null, [], false, false, true),
SetupHelper.GetETag(beforeArchive));
Assert.Equal(HttpStatusCode.OK, archive.StatusCode);
// The ordinary catalogue is the dashboard's source for operational signals.
// Archiving suppresses the warning while preserving the detail record for audit
// and possible later unarchiving.
var signalsAfterArchive = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?status=attention");
Assert.NotNull(signalsAfterArchive);
Assert.Empty(signalsAfterArchive.Items);
Assert.Equal(0, signalsAfterArchive.TotalCount);
var overview = await client.GetFromJsonAsync<InfrastructureOverviewResponse>(
"/api/v1/infrastructure/overview");
Assert.NotNull(overview);
Assert.Equal(0, overview.ServiceCount);
Assert.Equal(0, overview.UnhealthyServiceCount);
var archivedDetail = await client.GetFromJsonAsync<InfrastructureServiceDetail>(
$"/api/v1/infrastructure/services/{proposal.Id}");
Assert.NotNull(archivedDetail);
Assert.True(archivedDetail.EditorialProfile!.IsArchived);
Assert.Equal(proposal.Id, Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items).Id);
}
// V2-R2, docs/adr/ADR-026-v2-r2-infrastructure-scale-and-sync-durability.md, Finding
// B: two genuinely concurrent syncs of the *same* snapshot (same SourceHash) — e.g.
// a retried automation trigger firing twice — must never surface as an unhandled
// 500. The DB's own `ix_inventory_snapshots_workspace_id_source_hash` unique index
// is the real source of truth; this proves the loser of the race gets the same
// graceful "AlreadyImported" result the sequential retry path already returns
// (`A_disappeared_service_is_marked_missing_and_an_identical_snapshot_is_idempotent`
// above), not an exception, and that exactly one snapshot row is ever persisted.
[Fact]
public async Task Two_concurrent_syncs_of_the_identical_snapshot_never_500_and_only_one_snapshot_row_survives()
{
using var firstClient = Factory.CreateClient();
using var secondClient = Factory.CreateClient();
await SetupHelper.LoginAsync(firstClient, OwnerEmail, OwnerPassword);
await SetupHelper.LoginAsync(secondClient, OwnerEmail, OwnerPassword);
var snapshot = Snapshot("hash-concurrent-race", includeService: true, "running");
var responses = await Task.WhenAll(
SetupHelper.PostWithCsrfAsync(firstClient, "/api/v1/infrastructure/inventory/snapshots", snapshot),
SetupHelper.PostWithCsrfAsync(secondClient, "/api/v1/infrastructure/inventory/snapshots", snapshot));
Assert.All(responses, response => Assert.Equal(HttpStatusCode.OK, response.StatusCode));
var results = await Task.WhenAll(responses.Select(r => r.Content.ReadFromJsonAsync<InventorySyncResponse>()));
Assert.All(results, result => Assert.NotNull(result));
Assert.Single(results, result => !result!.AlreadyImported);
Assert.Single(results, result => result!.AlreadyImported);
// Both responses must reference the same real, persisted snapshot — the loser's
// graceful fallback resolves the winner's row rather than fabricating a result.
Assert.Equal(results[0]!.SnapshotId, results[1]!.SnapshotId);
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<KnowledgeHubDbContext>();
var snapshotCount = await db.InventorySnapshots.CountAsync(row => row.SourceHash == "hash-concurrent-race");
Assert.Equal(1, snapshotCount);
}
[Fact]
public async Task A_stale_editorial_profile_ETag_is_rejected()
{
using var client = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-concurrency", includeService: true, "running"));
// includeHidden=true, matching every other test in this file that fetches a
// freshly-synced (and therefore still-Proposed, unreviewed) service: the
// ordinary, non-includeHidden catalog now correctly excludes Proposed/Archived
// services (fixed alongside this test as part of the V2-M4 audit — see
// InfrastructureRepository.ListServicesAsync's own comment). This test exercises
// editorial-profile concurrency, not catalog visibility, so it only needs a
// reliable way to find the one service that exists.
var service = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
var request = new UpdateEditorialProfileRequest(
"Naam", "Samenvatting", "Other", "Doel", null, null, null, null, null, null,
null, null, null, null, [], false, false, false);
var first = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", request, "0");
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var stale = await SetupHelper.PutWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", request, "0");
Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode);
}
[Fact]
public async Task An_administrator_can_accept_a_proposed_service_and_the_decision_survives_sync()
{
using var client = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-review-1", includeService: true, "running"));
var service = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
Assert.Equal("Proposed", service.Presence);
var review = await SetupHelper.PostWithCsrfAsync(
client,
$"/api/v1/infrastructure/services/{service.Id}/review",
new ReviewInfrastructureServiceRequest("Accept", "Herkenbare productiecomponent."));
Assert.Equal(HttpStatusCode.OK, review.StatusCode);
var reviewed = await review.Content.ReadFromJsonAsync<ReviewInfrastructureServiceResponse>();
Assert.Equal("Present", reviewed!.Presence);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-review-2", includeService: true, "healthy"));
var afterSync = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
Assert.Equal("Present", afterSync.Presence);
}
[Fact]
public async Task A_curated_package_creates_editorial_context_and_confirms_validated_services_idempotently()
{
using var client = await AuthenticatedClientAsync();
var inventory = Snapshot("hash-curated-package", includeService: true, "running");
var package = new ImportCuratedInventoryRequest(
inventory,
[new CuratedServiceProfileRequest(
"docker:knowledge-hub", "ItWorx Knowledge Hub", "Infrastructure & Project Knowledge OS",
"Knowledge OS", "Operationele kennis centraal beheren.",
"https://example.test/knowledge-hub", "http://192.0.2.10:1235",
["infrastructuur", "productie"], true, false)]);
var first = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/curated-package", package);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var imported = await first.Content.ReadFromJsonAsync<ImportCuratedInventoryResponse>();
Assert.NotNull(imported);
Assert.Equal(1, imported.CreatedEditorialProfiles);
Assert.Equal(1, imported.ConfirmedServices);
var service = Assert.Single((await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
Assert.Equal("Present", service.Presence);
Assert.Equal("ItWorx Knowledge Hub", service.Name);
var detail = await client.GetFromJsonAsync<InfrastructureServiceDetail>(
$"/api/v1/infrastructure/services/{service.Id}");
Assert.Equal("Infrastructure & Project Knowledge OS", detail!.EditorialProfile!.Summary);
var retry = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/curated-package", package);
Assert.Equal(HttpStatusCode.OK, retry.StatusCode);
var retried = await retry.Content.ReadFromJsonAsync<ImportCuratedInventoryResponse>();
Assert.True(retried!.Inventory.AlreadyImported);
Assert.Equal(0, retried.CreatedEditorialProfiles);
Assert.Equal(0, retried.ConfirmedServices);
}
// --- Authorization boundaries (V2-M4: "make source-managed facts, editorial
// profiles and manual overrides ... safely editable" implies the write paths must
// actually be gated, not just modeled). RequireContentAccess(role, scope) is
// already exercised generically elsewhere (M6's ServiceAccountTests,
// RoleAuthorizationTests), but per CLAUDE.md's testing standard ("add integration
// tests for every persistence or authorization boundary") and the V2-M4 test gate
// explicitly naming infrastructure:read/write, each protected route group here
// gets its own boundary proof rather than relying solely on the shared mechanism
// being correct in general.
[Fact]
public async Task Unauthenticated_requests_to_infrastructure_endpoints_are_rejected()
{
using var client = Factory.CreateClient();
using var readResponse = await client.GetAsync("/api/v1/infrastructure/overview");
Assert.Equal(HttpStatusCode.Unauthorized, readResponse.StatusCode);
using var writeResponse = await client.PostAsJsonAsync(
"/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-unauth", includeService: false, "running"));
Assert.Equal(HttpStatusCode.Unauthorized, writeResponse.StatusCode);
}
[Fact]
public async Task A_reader_can_read_infrastructure_but_cannot_write_facts_overrides_or_trigger_a_sync()
{
using var adminClient = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-reader", includeService: true, "running"));
var service = Assert.Single((await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
using var readerClient = await RoleClientAsync(adminClient, "lezer-infra@itworx.test", "reader-strong-password", "Reader");
using var overview = await readerClient.GetAsync("/api/v1/infrastructure/overview");
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
var profileRequest = new UpdateEditorialProfileRequest(
"Naam", "Samenvatting", "Other", "Doel", null, null, null, null, null, null,
null, null, null, null, [], false, false, true);
var profileWrite = await SetupHelper.PutWithCsrfAsync(
readerClient, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", profileRequest, "0");
Assert.Equal(HttpStatusCode.Forbidden, profileWrite.StatusCode);
var overrideWrite = await SetupHelper.PostWithCsrfAsync(
readerClient,
$"/api/v1/infrastructure/services/{service.Id}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("\"Onterecht\""), "Poging."));
Assert.Equal(HttpStatusCode.Forbidden, overrideWrite.StatusCode);
var syncWrite = await SetupHelper.PostWithCsrfAsync(
readerClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-reader-2", includeService: false, "running"));
Assert.Equal(HttpStatusCode.Forbidden, syncWrite.StatusCode);
// The Reader's denied attempts must never have taken effect.
var stillDefault = await adminClient.GetFromJsonAsync<InfrastructureServiceDetail>(
$"/api/v1/infrastructure/services/{service.Id}");
Assert.Null(stillDefault!.EditorialProfile);
Assert.Empty(stillDefault.Overrides);
}
[Fact]
public async Task An_editor_can_edit_facts_and_overrides_but_cannot_review_proposals_or_trigger_a_sync()
{
using var adminClient = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-editor", includeService: true, "running"));
var service = Assert.Single((await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
Assert.Equal("Proposed", service.Presence);
using var editorClient = await RoleClientAsync(adminClient, "redacteur-infra@itworx.test", "editor-strong-password", "Editor");
var profileRequest = new UpdateEditorialProfileRequest(
"Naam", "Samenvatting", "Other", "Doel", null, null, null, null, null, null,
null, null, null, null, [], false, false, false);
var profileWrite = await SetupHelper.PutWithCsrfAsync(
editorClient, $"/api/v1/infrastructure/services/{service.Id}/editorial-profile", profileRequest, "0");
Assert.Equal(HttpStatusCode.OK, profileWrite.StatusCode);
var overrideWrite = await SetupHelper.PostWithCsrfAsync(
editorClient,
$"/api/v1/infrastructure/services/{service.Id}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("\"itworx-knowledge-hub (redacteurcorrectie)\""), "Redacteurcorrectie."));
Assert.Equal(HttpStatusCode.OK, overrideWrite.StatusCode);
// Review (Administrator-only per InfrastructureEndpoints.cs) and triggering a
// sync (also Administrator-only) must still be denied to an Editor even though
// both are nominally "write" actions — the role check is independent of and
// stricter than the scope check.
var review = await SetupHelper.PostWithCsrfAsync(
editorClient,
$"/api/v1/infrastructure/services/{service.Id}/review",
new ReviewInfrastructureServiceRequest("Accept", "Poging door redacteur."));
Assert.Equal(HttpStatusCode.Forbidden, review.StatusCode);
var syncWrite = await SetupHelper.PostWithCsrfAsync(
editorClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-editor-2", includeService: false, "running"));
Assert.Equal(HttpStatusCode.Forbidden, syncWrite.StatusCode);
var stillProposed = await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true");
Assert.Equal("Proposed", Assert.Single(stillProposed!.Items).Presence);
}
[Fact]
public async Task A_bearer_token_with_infrastructure_read_can_read_but_not_write_horizontal_and_vertical_escalation()
{
using var adminClient = await AuthenticatedClientAsync();
await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/infrastructure/inventory/snapshots", Snapshot("hash-bearer-read", includeService: true, "running"));
var service = Assert.Single((await adminClient.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true"))!.Items);
var created = await CreateServiceAccountAsync(adminClient, "Infra-read-bot", Scopes.InfrastructureRead);
using var bearerClient = BearerClient(created.RawToken);
using var readResponse = await bearerClient.GetAsync("/api/v1/infrastructure/overview");
Assert.Equal(HttpStatusCode.OK, readResponse.StatusCode);
// infrastructure:read must not implicitly grant infrastructure:write — a
// vertical privilege-escalation attempt, matching M6's established pattern for
// every other scoped resource in this API.
var overrideWrite = await bearerClient.PostAsJsonAsync(
$"/api/v1/infrastructure/services/{service.Id}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("\"Onterecht\""), "Bearer-poging."));
Assert.True(
overrideWrite.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected the write attempt to be denied, but got {overrideWrite.StatusCode}.");
}
[Fact]
public async Task A_bearer_token_without_any_infrastructure_scope_cannot_read_or_write_horizontal_escalation()
{
using var adminClient = await AuthenticatedClientAsync();
// A token scoped to a completely unrelated resource — never infrastructure:read
// or infrastructure:write. Proves scopes for one resource never leak access to
// another (horizontal privilege escalation), matching ServiceAccountTests'
// "pages:write does not grant project access" precedent for this resource.
var created = await CreateServiceAccountAsync(adminClient, "Search-only-bot", Scopes.KnowledgeSearch);
using var bearerClient = BearerClient(created.RawToken);
using var readResponse = await bearerClient.GetAsync("/api/v1/infrastructure/overview");
Assert.True(
readResponse.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized,
$"Expected the read attempt to be denied, but got {readResponse.StatusCode}.");
}
private async Task<HttpClient> RoleClientAsync(HttpClient adminClient, string email, string password, string role)
{
var createResponse = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/admin/users", new CreateUserRequest($"Test {role}", email, password, role));
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, email, password);
return client;
}
private static async Task<CreatedServiceAccountToken> CreateServiceAccountAsync(HttpClient adminClient, string name, params string[] scopes)
{
var response = await SetupHelper.PostWithCsrfAsync(
adminClient, "/api/v1/service-accounts", new CreateServiceAccountRequest(name, "Testaccount.", scopes));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<CreatedServiceAccountToken>();
Assert.NotNull(created);
return created;
}
private HttpClient BearerClient(string rawToken)
{
var client = Factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", rawToken);
return client;
}
private async Task<HttpClient> AuthenticatedClientAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
return client;
}
private static SynchronizeInventoryRequest Snapshot(
string hash, bool includeService, string runtimeStatus, string health = "Healthy")
{
var services = includeService
? new[]
{
new InventoryServiceRequest(
"docker:knowledge-hub", "host:unraid", "itworx-knowledge-hub", "container", runtimeStatus,
health, "itworx/knowledge-hub:v2", "sha256:example", "10001:10001", true, true, false, "High",
[new InventoryComponentRequest("container:app", "app", "container", "abc123", runtimeStatus, "itworx/knowledge-hub:v2", "High")],
[new InventoryEndpointRequest("port:1235", "http", "192.0.2.10", 1235, 8080, "http://192.0.2.10:1235", false, "High")],
[new InventoryNetworkRequest("network:internal", "knowledge-internal", "bridge", "local", true, "High")],
[new InventoryVolumeRequest("volume:attachments", "attachments", "/app/attachments", "appdata", false, 1024, "High")],
[new InventoryDatabaseRequest("database:postgres", "knowledge_hub", "postgresql", "17", "db", 5432, false, "High")],
[], [new InventoryHealthRequest("health:app", health, "container", 5, "High")],
[new InventoryDeploymentRequest("deployment:app", "https://example.test/repo", "abc", "itworx/knowledge-hub:v2", "sha256:example", null, "High")],
[new InventoryBackupRequest("backup:daily", "pg_dump", "daily", DateTimeOffset.UtcNow.AddHours(-1), true, "High")])
}
: [];
return new SynchronizeInventoryRequest(
$"inventory-{hash}", hash, DateTimeOffset.UtcNow.AddMinutes(-1),
[new InventoryHostRequest("host:unraid", "Tower", "Unraid", "6.12", "x86_64", 16, 64L * 1024 * 1024 * 1024)],
services);
}
}
@@ -0,0 +1,205 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using ItWorx.KnowledgeHub.Contracts.Infrastructure;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.InfrastructureKnowledge;
/// <summary>
/// The explicit per-<c>InfrastructureObjectKind</c> override field catalog
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H3):
/// <c>ManualOverride</c> must never accept an arbitrary field name or unconstrained
/// JSON.
/// </summary>
[Collection(PostgresTestGroup.Name)]
public sealed class ManualOverrideCatalogTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres)
{
private async Task<(HttpClient Client, Guid ServiceId)> AuthenticatedClientWithServiceAsync()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/infrastructure/inventory/snapshots", MinimalSnapshot($"hash-{Guid.NewGuid():N}"));
var services = await client.GetFromJsonAsync<InfrastructureServiceListResponse>(
"/api/v1/infrastructure/services?includeHidden=true");
var service = Assert.Single(services!.Items);
return (client, service.Id);
}
[Fact]
public async Task The_catalog_endpoint_lists_exactly_the_fields_the_UI_may_offer_for_a_service()
{
var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
var catalog = await client.GetFromJsonAsync<ManualOverrideFieldCatalogResponse>(
"/api/v1/infrastructure/overrides/catalog?targetKind=Service");
Assert.NotNull(catalog);
Assert.Equal("Service", catalog.TargetKind);
Assert.Equal(
new[] { "detectedName", "runtimeKind", "runtimeStatus", "imageReference", "health", "confidence" },
catalog.Fields.Select(field => field.FieldName));
var health = Assert.Single(catalog.Fields, field => field.FieldName == "health");
Assert.Equal("Enum", health.ValueKind);
Assert.Contains("Healthy", health.AllowedEnumValues!);
}
[Fact]
public async Task An_unknown_field_name_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("arbitraryNewField", JsonSerializer.Deserialize<JsonElement>("\"waarde\""), "Poging."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_non_string_JSON_value_is_rejected_even_for_a_text_field()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("12345"), "Verkeerd type."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_object_JSON_value_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.Deserialize<JsonElement>("{\"nested\":true}"), "Verkeerd type."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_oversized_JSON_value_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var huge = new string('a', 5000);
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("detectedName", JsonSerializer.SerializeToElement(huge), "Te groot."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task A_string_value_longer_than_the_field_specific_maximum_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
// Under the 4000-byte envelope cap but over runtimeKind's own 100-character
// field-specific bound — proves both limits are enforced independently.
var tooLongForField = new string('a', 200);
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("runtimeKind", JsonSerializer.SerializeToElement(tooLongForField), "Te lang voor dit veld."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task An_invalid_enum_value_for_health_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("health", JsonSerializer.Deserialize<JsonElement>("\"NietBestaand\""), "Ongeldige status."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Theory]
[InlineData("Unknown")]
[InlineData("Healthy")]
[InlineData("Degraded")]
[InlineData("Unhealthy")]
[InlineData("Stopped")]
public async Task Every_real_health_enum_value_is_accepted(string health)
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("health", JsonSerializer.SerializeToElement(health), "Geldige status."));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task An_invalid_confidence_enum_value_is_rejected()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var response = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("confidence", JsonSerializer.Deserialize<JsonElement>("\"Zeker\""), "Ongeldig."));
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task Malformed_JSON_in_the_raw_request_never_produces_a_500()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var csrf = await SetupHelper.GetCsrfTokenAsync(client);
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/infrastructure/services/{serviceId}/overrides")
{
Content = new StringContent("{ not valid json", System.Text.Encoding.UTF8, "application/json")
};
request.Headers.Add(csrf.HeaderName, csrf.Value);
var response = await client.SendAsync(request);
Assert.True(
(int)response.StatusCode < 500,
$"Malformed JSON must never reach a 5xx response, got {response.StatusCode}.");
}
[Fact]
public async Task A_valid_override_can_be_reset_by_removing_it_and_the_removal_is_idempotent_to_retry_by_id()
{
var (client, serviceId) = await AuthenticatedClientWithServiceAsync();
var setResponse = await SetupHelper.PostWithCsrfAsync(
client, $"/api/v1/infrastructure/services/{serviceId}/overrides",
new SetManualOverrideRequest("runtimeStatus", JsonSerializer.SerializeToElement("gecorrigeerd"), "Reden."));
Assert.Equal(HttpStatusCode.OK, setResponse.StatusCode);
var created = await setResponse.Content.ReadFromJsonAsync<JsonElement>();
var overrideId = created.GetProperty("id").GetGuid();
var removeResponse = await SetupHelper.DeleteWithCsrfAsync(client, $"/api/v1/infrastructure/overrides/{overrideId}");
Assert.Equal(HttpStatusCode.NoContent, removeResponse.StatusCode);
var detail = await client.GetFromJsonAsync<InfrastructureServiceDetail>($"/api/v1/infrastructure/services/{serviceId}");
Assert.Empty(detail!.Overrides);
// Removing an already-removed override is a clear not-found, never a crash.
var retryRemove = await SetupHelper.DeleteWithCsrfAsync(client, $"/api/v1/infrastructure/overrides/{overrideId}");
Assert.Equal(HttpStatusCode.NotFound, retryRemove.StatusCode);
}
private static SynchronizeInventoryRequest MinimalSnapshot(string hash) =>
new(
$"inventory-{hash}", hash, DateTimeOffset.UtcNow.AddMinutes(-1),
[new InventoryHostRequest("host:unraid", "Tower", "Unraid", "6.12", "x86_64", 16, 64L * 1024 * 1024 * 1024)],
[new InventoryServiceRequest(
"docker:knowledge-hub", "host:unraid", "itworx-knowledge-hub", "container", "running",
"Healthy", "itworx/knowledge-hub:v2", "sha256:example", "10001:10001", true, true, false, "High",
[], [], [], [], [], [], [], [], [])]);
}