Files
itworx-knowledge-hub-Public/tests/backend/ItWorx.KnowledgeHub.IntegrationTests/InfrastructureKnowledge/InfrastructureSynchronizationTests.cs
T

515 lines
29 KiB
C#

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);
}
}