515 lines
29 KiB
C#
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);
|
|
}
|
|
}
|