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; using Microsoft.Extensions.DependencyInjection; 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( "/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())!.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(); var registered = await db.Set().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( "/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(); await Assert.ThrowsAsync(() => 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() { 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(); Assert.NotNull(firstSync); Assert.Equal(1, firstSync.AddedServices); var services = await client.GetFromJsonAsync( "/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(); Assert.NotNull(detail); Assert.Single(detail.Components); Assert.Single(detail.Endpoints); Assert.Single(detail.Volumes); Assert.Single(detail.Databases); var storage = await client.GetFromJsonAsync( "/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("\"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( $"/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>( "/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(); 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(); Assert.True(retry!.AlreadyImported); var services = await client.GetFromJsonAsync( "/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( "/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( "/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( "/api/v1/infrastructure/services?status=attention"); Assert.NotNull(signalsAfterArchive); Assert.Empty(signalsAfterArchive.Items); Assert.Equal(0, signalsAfterArchive.TotalCount); var overview = await client.GetFromJsonAsync( "/api/v1/infrastructure/overview"); Assert.NotNull(overview); Assert.Equal(0, overview.ServiceCount); Assert.Equal(0, overview.UnhealthyServiceCount); var archivedDetail = await client.GetFromJsonAsync( $"/api/v1/infrastructure/services/{proposal.Id}"); Assert.NotNull(archivedDetail); Assert.True(archivedDetail.EditorialProfile!.IsArchived); Assert.Equal(proposal.Id, Assert.Single((await client.GetFromJsonAsync( "/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())); 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(); 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( "/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( "/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(); 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( "/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(); Assert.NotNull(imported); Assert.Equal(1, imported.CreatedEditorialProfiles); Assert.Equal(1, imported.ConfirmedServices); var service = Assert.Single((await client.GetFromJsonAsync( "/api/v1/infrastructure/services?includeHidden=true"))!.Items); Assert.Equal("Present", service.Presence); Assert.Equal("ItWorx Knowledge Hub", service.Name); var detail = await client.GetFromJsonAsync( $"/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(); 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( "/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("\"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( $"/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( "/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("\"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( "/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( "/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("\"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 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 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(); 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 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); } }