diff --git a/docs/architecture/application-read-leak-evidence.md b/docs/architecture/application-read-leak-evidence.md new file mode 100644 index 0000000..5689d24 --- /dev/null +++ b/docs/architecture/application-read-leak-evidence.md @@ -0,0 +1,9 @@ +# Application read isolation evidence + +Issue #157 tracks tenant-owned read isolation across the backend, HTTP API, and MCP. `ApplicationReadLeakMatrixE2ETests` supplies a two-populated-tenant broker case in standalone and split API/worker hosts. One signed-in member owns both tenants, and an unrelated signed-in member exercises denial. + +The test creates a distinct application and two system instances per tenant, plus two current draft boundary references for the application and one instance in each tenant. It verifies positive exact application-revision and system-instance reads over HTTP and MCP. It follows every page of each tenant's application-revision, system-instance, application-boundary-reference, and system-instance-boundary-reference lists with `limit=1` over both transports, checking tenant IDs, record IDs, and tenant-specific names or reference text. Cursors issued in tenant A are rejected in tenant B over HTTP and MCP. + +For each list and exact read, a foreign application or instance ID under the other tenant returns `NotFound`; the unrelated member is denied. Application-change previews for both tenants return only their own current draft application boundary references, while a foreign application ID and the unrelated member are denied over HTTP and MCP. + +This evidence covers current draft boundary references and the populated preview `boundary_references` field. It does not seed approved boundary versions, control draft references, or other preview contexts. It does not exercise a stopped worker or replay, or prove the top-level application list. The broader #157 inventory is in [tenant-read-leak-matrix.md](tenant-read-leak-matrix.md). diff --git a/docs/architecture/tenant-read-leak-matrix.md b/docs/architecture/tenant-read-leak-matrix.md index 56ab202..f098b5b 100644 --- a/docs/architecture/tenant-read-leak-matrix.md +++ b/docs/architecture/tenant-read-leak-matrix.md @@ -10,18 +10,18 @@ The route inventory below follows `src/Compliance.App/Program.cs`. | --- | --- | --- | | RBAC `/teams`, `/roles`, team members, role permissions, role teams and the matching MCP lists | `TenantReadLeakMatrixE2ETests.ShouldScopeRbacListsSearchAndCursorsGivenTwoTenants`: both host modes; two-tenant team/role search and HTTP/MCP cursors through exhaustion, role-permission search cursors through exhaustion, nested search, foreign-parent empty pages, and outsider denial. | Add multi-row cursor cases for team members and role teams when those relationships gain a suitable seed fixture. | | Application import batch GET, `/rows`, `/preview` and matching MCP reads | `TenantReadLeakMatrixE2ETests.ShouldScopeImportCountsRowsAndPreviewGivenTwoTenants`: both modes; distinct nonzero row and invalid counters, row and preview content, HTTP row cursor and foreign-cursor rejection, cross-tenant IDs, outsider denial. | Add preview and MCP row cursor variants. The progress fields are currently initialized to zero and have no update path; prove their isolation with processing in #213. No import list or separate count route exists. | -| Application list and boundary references | `ProjectionReadConsistencyE2ETests.ShouldNeverReturnStaleProjectionOrCrossTenantRowsGivenStandaloneOrSplitHost` covers application-list isolation and boundary-reference exact/non-disclosure in both modes. | Application revision, system-instance and reference list pagination, change preview, and remaining HTTP/MCP permutations. | -| Snapshot read, verification and manifest regeneration | `SnapshotE2ETests.ShouldFreezeExactScopeGivenStandaloneOrSplitWorker` covers cross-tenant verification/manifest and outsider denial in both modes. | Two-tenant snapshot list and cursor, plus full MCP list proof. | +| Application list, revisions, system instances, references and change preview | `ProjectionReadConsistencyE2ETests.ShouldNeverReturnStaleProjectionOrCrossTenantRowsGivenStandaloneOrSplitHost` covers application-list isolation in both modes. `ApplicationReadLeakMatrixE2ETests.ShouldKeepApplicationReadPagesAndPreviewsWithinTenantGivenTwoPopulatedTenants` adds HTTP/MCP exact revision and instance reads, `limit=1` revision, instance and application/instance reference lists through cursor exhaustion, strict foreign-cursor rejection, populated change previews, foreign IDs and outsider denial in both modes. [Detailed evidence](application-read-leak-evidence.md). | The top-level application list still lacks this two-populated-tenant cursor proof. The new fixture covers current draft boundary references, not approved versions, control references or stopped-worker replay. | +| Boundary program lists, versions, decisions and impact preview | `BoundarySnapshotReadLeakMatrixE2ETests.ShouldScopeBoundaryAndSnapshotReadsGivenTwoTenants` covers HTTP/MCP paged boundary, approved-version and decision lists through exhaustion, exact boundary/draft/version/effective/decision reads, and populated impact changes in both modes. Foreign IDs, outsider denial and strict foreign-cursor rejection are asserted. | One approved version per tenant is seeded; nested impact contributions can be empty. No separate count route exists. | +| Snapshot list, detail, verification and manifest regeneration | The same `BoundarySnapshotReadLeakMatrixE2ETests` case covers HTTP/MCP paged snapshot lists through exhaustion, exact snapshot, verification and regenerated manifest content in both modes, with foreign IDs, outsider denial and strict foreign-cursor rejection. `SnapshotE2ETests.ShouldFreezeExactScopeGivenStandaloneOrSplitWorker` supplies additional freeze/scope evidence. | Two snapshots per tenant are seeded; stopped-worker replay has separate capability evidence. No separate count route exists. | +| Program and client-service current, revision, list, history and setup-work reads | `ProgramReadLeakMatrixE2ETests.ShouldScopeProgramAndServicePagesGivenTwoTenants` covers HTTP/MCP exact current and revision reads, six paged list/history/setup-work families, seeded content and versions, strict foreign-cursor rejection, foreign IDs and outsider denial in both modes. [Detailed evidence](program-service-read-leak-evidence.md). | The fixture does not cover stopped-worker replay, Program stage transitions or future setup-work inputs beyond boundaries. These routes expose no separate count or search operation. | | Platform metadata exception | `OperatorPortfolioE2ETests.ShouldListPlatformTenantMetadataGivenConfiguredOperatorAndHostMode` covers operator-only tenant metadata pagination in both modes. | Keep this separate from client business-record access. | | Tenant detail, self-list, operator member list, invitation list, effective member access and matching MCP reads | `TenantIdentityReadLeakMatrixE2ETests.ShouldScopeTenantMemberInvitationAndSelfReadsGivenTwoTenants`: both modes; separate tenant owners and an outsider, operator metadata and member-list authority, HTTP/MCP self-list and invitation cursors through exhaustion, strict foreign invitation cursor rejection, foreign paths and email filters, and foreign member-access denial. [Detailed evidence](tenant-identity-read-leak-matrix.md). | Member-list pagination after a second membership activates and actor replacement/deprovisioning lifecycle proof. These reads have no total-count field. | -Still open for the same two-tenant, two-host HTTP/MCP matrix: program list, -revision and setup-work reads; control, commitment and risk draft/history pages; -client-service tenant and program lists/history; boundary program lists, -versions, decisions and impact preview; and the remaining application and -snapshot reads above. Existing capability tests often prove standalone -cross-tenant denial or split-host recovery, but that does not prove every list -and cursor in both modes. +Still open for the same two-tenant, two-host HTTP/MCP matrix: control, +commitment and risk draft/current/history reads, the top-level application +list cursor and the remaining application contexts above. Existing capability +tests often prove standalone cross-tenant denial or split-host recovery, but that +does not prove every list and cursor in both modes. The current API has no separate tenant-owned count, export, job, notification or artifact-content route. Import batch status carries row and invalid counts diff --git a/test/Compliance.Tests/E2E/ApplicationReadLeakMatrixE2ETests.cs b/test/Compliance.Tests/E2E/ApplicationReadLeakMatrixE2ETests.cs new file mode 100644 index 0000000..66b7ba4 --- /dev/null +++ b/test/Compliance.Tests/E2E/ApplicationReadLeakMatrixE2ETests.cs @@ -0,0 +1,606 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Bdgrz.Compliance; +using Cntryl.Portia.Testing; +using Microsoft.Extensions.Hosting; + +namespace Bdgrz.Compliance.Tests.E2E; + +/// +/// EN-01: two populated tenants must remain separate across application history, instance, +/// boundary-reference, and change-preview reads in standalone and split API/worker hosts. +/// +[Collection(BrokerCollectionDefinition.Name)] +[Trait("Category", "BrokerIntegration")] +public sealed class ApplicationReadLeakMatrixE2ETests(BrokerStackFixture broker) + : IClassFixture +{ + static readonly string[] SecurityCategory = ["security"]; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ShouldKeepApplicationReadPagesAndPreviewsWithinTenantGivenTwoPopulatedTenants( + bool splitHosts) + { + // Arrange + var applicationName = $"compliance-application-leak-{Guid.NewGuid():N}"; + using var worker = splitHosts ? BuildWorker(applicationName) : null; + if (worker is not null) + await worker.StartAsync(); + + try + { + await using var factory = E2EAppFactory.Create(broker, applicationName); + var previousMode = Environment.GetEnvironmentVariable("COMPLIANCE_HOST_MODE"); + HttpClient owner; + try + { + Environment.SetEnvironmentVariable("COMPLIANCE_HOST_MODE", + splitHosts ? "api" : "standalone"); + owner = factory.CreateClient(); + } + finally + { + Environment.SetEnvironmentVariable("COMPLIANCE_HOST_MODE", previousMode); + } + + using (owner) + using (var outsider = factory.CreateClient()) + { + await TenantInvitationE2ETests.LoginAsync(owner, + $"application-matrix-owner-{Guid.NewGuid():N}@example.com"); + await TenantInvitationE2ETests.LoginAsync(outsider, + $"application-matrix-outsider-{Guid.NewGuid():N}@example.com"); + var first = await SeedAsync(owner, "A"); + var second = await SeedAsync(owner, "B"); + + // Act + // Assert: every page, including a cursor carried to the other tenant, + // has only that tenant's positive records. + foreach (var tenant in new[] { first, second }) + { + foreach (var spec in ReadSpecs(tenant)) + await AssertHttpPagesAsync(owner, spec); + using (var revision = await owner.GetAsync( + $"/api/v1/tenants/{tenant.TenantId}/applications/" + + $"{tenant.ApplicationId}/revisions/3")) + using (var instance = await owner.GetAsync( + $"/api/v1/tenants/{tenant.TenantId}/applications/" + + $"{tenant.ApplicationId}/system-instances/{tenant.Instances[0]}")) + { + Assert.Equal(HttpStatusCode.OK, revision.StatusCode); + Assert.Equal(HttpStatusCode.OK, instance.StatusCode); + AssertExactBelongsTo(await ReadAsync(revision), tenant, + "application_id", tenant.ApplicationId); + AssertExactBelongsTo(await ReadAsync(instance), tenant, + "system_instance_id", tenant.Instances[0]); + } + await AssertHttpPreviewAsync(owner, tenant, first, second); + } + + foreach (var spec in ReadSpecs(first)) + { + using var firstPage = await owner.GetAsync(spec.Path + "?limit=1"); + Assert.Equal(HttpStatusCode.OK, firstPage.StatusCode); + var cursor = (await ReadAsync(firstPage)).GetProperty("next_cursor").GetString(); + Assert.False(string.IsNullOrWhiteSpace(cursor)); + var foreignSpec = ReadSpecs(second).Single(other => other.Tool == spec.Tool); + using var foreignPage = await owner.GetAsync(foreignSpec.Path + + "?limit=1&cursor=" + Uri.EscapeDataString(cursor)); + Assert.Equal(HttpStatusCode.BadRequest, foreignPage.StatusCode); + } + + await using (var mcp = await McpScenario.ConnectAsync(owner, + new Uri(owner.BaseAddress!, "/mcp"))) + { + foreach (var tenant in new[] { first, second }) + { + foreach (var spec in ReadSpecs(tenant)) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + string? cursor = null; + var pageCount = 0; + do + { + Assert.True(pageCount++ < 50, "MCP application read cursor did not terminate."); + var args = new Dictionary(spec.Args) + { + ["limit"] = 1, + }; + if (cursor is not null) + args["cursor"] = cursor; + var call = await mcp.When(spec.Tool, args).ExpectSuccess(); + var page = Assert.IsType(call.StructuredJson) + .GetProperty("result"); + AssertPageBelongsTo(page, spec); + foreach (var item in page.GetProperty("items").EnumerateArray()) + Assert.True(seen.Add(item.GetProperty(spec.IdProperty).ToString())); + cursor = page.GetProperty("next_cursor").GetString(); + } while (cursor is not null && seen.Count < spec.ExpectedIds.Length); + Assert.Equal(spec.ExpectedIds.Order(StringComparer.OrdinalIgnoreCase), + seen.Order(StringComparer.OrdinalIgnoreCase)); + Assert.Null(cursor); + } + + var previewCall = await mcp.When("bdgrz.application.change.preview", + PreviewArgs(tenant)).ExpectSuccess(); + AssertPreviewBelongsTo(Assert.IsType(previewCall.StructuredJson) + .GetProperty("result"), tenant, first, second); + var revisionCall = await mcp.When("bdgrz.application.revision.get", + new Dictionary + { + ["tenant_id"] = tenant.TenantId, + ["application_id"] = tenant.ApplicationId, + ["revision"] = 3, + }).ExpectSuccess(); + AssertExactBelongsTo(Assert.IsType(revisionCall.StructuredJson) + .GetProperty("result"), tenant, "application_id", + tenant.ApplicationId); + var instanceCall = await mcp.When("bdgrz.system_instance.get", + new Dictionary + { + ["tenant_id"] = tenant.TenantId, + ["application_id"] = tenant.ApplicationId, + ["system_instance_id"] = tenant.Instances[0], + }).ExpectSuccess(); + AssertExactBelongsTo(Assert.IsType(instanceCall.StructuredJson) + .GetProperty("result"), tenant, "system_instance_id", + tenant.Instances[0]); + } + + foreach (var spec in ReadSpecs(first)) + { + var firstArgs = new Dictionary(spec.Args) + { + ["limit"] = 1, + }; + var firstPage = await mcp.When(spec.Tool, firstArgs).ExpectSuccess(); + var cursor = Assert.IsType(firstPage.StructuredJson) + .GetProperty("result").GetProperty("next_cursor").GetString(); + Assert.False(string.IsNullOrWhiteSpace(cursor)); + var foreignSpec = ReadSpecs(second).Single(other => other.Tool == spec.Tool); + var foreignArgs = new Dictionary(foreignSpec.Args) + { + ["limit"] = 1, + ["cursor"] = cursor, + }; + _ = await mcp.When(foreignSpec.Tool, foreignArgs) + .ExpectFailure("Validation"); + } + + foreach (var spec in ReadSpecs(first)) + { + var foreignArgs = new Dictionary(spec.Args) + { + ["tenant_id"] = second.TenantId, + }; + _ = await mcp.When(spec.Tool, foreignArgs).ExpectFailure("NotFound"); + } + _ = await mcp.When("bdgrz.application.change.preview", + PreviewArgs(first, second.TenantId)).ExpectFailure("NotFound"); + _ = await mcp.When("bdgrz.application.revision.get", + new Dictionary + { + ["tenant_id"] = second.TenantId, + ["application_id"] = first.ApplicationId, + ["revision"] = 1, + }).ExpectFailure("NotFound"); + _ = await mcp.When("bdgrz.system_instance.get", + new Dictionary + { + ["tenant_id"] = second.TenantId, + ["application_id"] = second.ApplicationId, + ["system_instance_id"] = first.Instances[0], + }).ExpectFailure("NotFound"); + _ = await mcp.When("bdgrz.system_instance.boundary_references.list", + new Dictionary + { + ["tenant_id"] = second.TenantId, + ["application_id"] = second.ApplicationId, + ["system_instance_id"] = first.Instances[0], + }).ExpectFailure("NotFound"); + } + + await using (var outsiderMcp = await McpScenario.ConnectAsync(outsider, + new Uri(outsider.BaseAddress!, "/mcp"))) + { + foreach (var spec in ReadSpecs(first)) + _ = await outsiderMcp.When(spec.Tool, spec.Args) + .ExpectFailure("NotFound"); + _ = await outsiderMcp.When("bdgrz.application.change.preview", + PreviewArgs(first)).ExpectFailure("NotFound"); + _ = await outsiderMcp.When("bdgrz.application.revision.get", + new Dictionary + { + ["tenant_id"] = first.TenantId, + ["application_id"] = first.ApplicationId, + ["revision"] = 3, + }).ExpectFailure("NotFound"); + _ = await outsiderMcp.When("bdgrz.system_instance.get", + new Dictionary + { + ["tenant_id"] = first.TenantId, + ["application_id"] = first.ApplicationId, + ["system_instance_id"] = first.Instances[0], + }).ExpectFailure("NotFound"); + } + + await AssertHttpDenialsAsync(owner, outsider, first, second); + } + } + finally + { + if (worker is not null) + await worker.StopAsync(); + } + } + + IHost BuildWorker(string applicationName) + { + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings + { + EnvironmentName = "Development", + }); + builder.Configuration["Fitz:Endpoint"] = broker.WebSocketEndpoint; + builder.Configuration["Fitz:ApplicationName"] = applicationName; + builder.Configuration["Fitz:StartupTimeoutSeconds"] = "30"; + builder.Services.AddCompliance(builder.Configuration, developerAuthentication: true) + .AddWorkers(); + return builder.Build(); + } + + static async Task SeedAsync(HttpClient owner, string label) + { + var tenantId = Guid.Parse((await PostUntilAuthorizedAsync(owner, + "/api/v1/tenants", new + { + name = "Application matrix " + label, + slug = $"app-matrix-{Guid.NewGuid():N}"[..24], + })).GetProperty("tenant_id").GetString()!); + var applicationsPath = $"/api/v1/tenants/{tenantId}/applications"; + var applicationId = Guid.Parse((await PostUntilAuthorizedAsync(owner, applicationsPath, + new { name = "Payroll " + label, purpose = "Run payroll " + label })) + .GetProperty("application_id").GetString()!); + var applicationPath = $"{applicationsPath}/{applicationId}"; + _ = await WaitForOkAsync(() => owner.GetAsync(applicationPath)); + var instances = new Guid[2]; + for (var index = 0; index < instances.Length; index++) + { + using var declared = await owner.PostAsJsonAsync(applicationPath + "/system-instances", + new + { + expected_application_revision = index + 1, + name = $"Payroll {label} instance {index}", + kind = "production", + source_identifier = $"payroll-{label}-{index}", + }); + Assert.Equal(HttpStatusCode.OK, declared.StatusCode); + instances[index] = Guid.Parse((await ReadAsync(declared)) + .GetProperty("system_instance_id").GetString()!); + } + _ = await WaitForOkAsync(() => owner.GetAsync(applicationPath + "/revisions/3")); + _ = await WaitForOkAsync(() => owner.GetAsync( + applicationPath + "/system-instances?minimum_application_revision=3")); + + var programId = Guid.Parse((await PostUntilAuthorizedAsync(owner, + $"/api/v1/tenants/{tenantId}/programs", new + { + name = "Application matrix " + label, + plan = new + { + target_readiness_date = "2027-01-31", + target_type_i_as_of_date = "2027-03-31", + target_type_ii_start_date = "2027-04-01", + target_type_ii_end_date = "2028-03-31", + readiness_advisor = "Advisor", + audit_firm = (string?)null, + }, + })).GetProperty("program_id").GetString()!); + var boundaryPath = $"/api/v1/tenants/{tenantId}/programs/{programId}/boundaries"; + var applicationBoundaries = new Guid[2]; + var instanceBoundaries = new Guid[2]; + for (var index = 0; index < 2; index++) + { + applicationBoundaries[index] = await CreateBoundaryAsync(owner, boundaryPath, + "application", applicationId, label); + instanceBoundaries[index] = await CreateBoundaryAsync(owner, boundaryPath, + "system_instance", instances[0], label); + } + await WaitForItemsAsync(owner, applicationPath + "/boundary-references", 2); + await WaitForItemsAsync(owner, applicationPath + "/system-instances/" + instances[0] + + "/boundary-references", 2); + _ = await WaitForOkAsync(() => owner.PostAsJsonAsync( + applicationPath + "/change-previews", new + { + expected_application_revision = 3, + change_kind = "retire", + })); + return new Seed(label, tenantId, applicationId, instances, applicationBoundaries, + instanceBoundaries); + } + + static async Task CreateBoundaryAsync(HttpClient owner, string path, + string subjectType, Guid governedRecordId, string label) + { + using var response = await owner.PostAsJsonAsync(path, new + { + content = new + { + statement = $"Application matrix boundary {label}.", + engagement_stage = "readiness", + trust_services_categories = SecurityCategory, + entries = new[] + { + new + { + entry_id = Guid.NewGuid(), + kind = "inclusion", + subject_type = subjectType, + subject = $"Payroll {label}", + governed_record_id = governedRecordId, + owner_reference = "Operations", + rationale = $"Declared in scope for tenant {label}.", + unresolved = false, + }, + }, + }, + }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return Guid.Parse((await ReadAsync(response)).GetProperty("boundary_id").GetString()!); + } + + static ReadSpec[] ReadSpecs(Seed seed) + { + var applicationPath = $"/api/v1/tenants/{seed.TenantId}/applications/" + + seed.ApplicationId; + return + [ + new(applicationPath + "/revisions", "bdgrz.application.revision.list", "revision", + ["1", "2", "3"], seed.Label, seed.TenantId, seed.ApplicationId, + new Dictionary + { + ["tenant_id"] = seed.TenantId, + ["application_id"] = seed.ApplicationId, + ["minimum_application_revision"] = 3, + }, "minimum_application_revision=3"), + new(applicationPath + "/system-instances", "bdgrz.system_instance.list", + "system_instance_id", seed.Instances.Select(id => id.ToString()).ToArray(), + seed.Label, seed.TenantId, seed.ApplicationId, new Dictionary + { + ["tenant_id"] = seed.TenantId, + ["application_id"] = seed.ApplicationId, + ["minimum_application_revision"] = 3, + }, "minimum_application_revision=3"), + new(applicationPath + "/boundary-references", + "bdgrz.application.boundary_references.list", "boundary_id", + seed.ApplicationBoundaries.Select(id => id.ToString()).ToArray(), + seed.Label, seed.TenantId, seed.ApplicationId, new Dictionary + { + ["tenant_id"] = seed.TenantId, + ["application_id"] = seed.ApplicationId, + }), + new(applicationPath + "/system-instances/" + seed.Instances[0] + + "/boundary-references", "bdgrz.system_instance.boundary_references.list", + "boundary_id", seed.InstanceBoundaries.Select(id => id.ToString()).ToArray(), + seed.Label, seed.TenantId, seed.Instances[0], new Dictionary + { + ["tenant_id"] = seed.TenantId, + ["application_id"] = seed.ApplicationId, + ["system_instance_id"] = seed.Instances[0], + }), + ]; + } + + static async Task AssertHttpPagesAsync(HttpClient owner, ReadSpec spec) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + string? cursor = null; + var pageCount = 0; + do + { + Assert.True(pageCount++ < 50, "HTTP application read cursor did not terminate."); + var query = "?limit=1" + (spec.MinimumRevisionQuery is null ? string.Empty : + "&" + spec.MinimumRevisionQuery) + (cursor is null ? string.Empty : + "&cursor=" + Uri.EscapeDataString(cursor)); + using var response = await owner.GetAsync(spec.Path + query); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var page = await ReadAsync(response); + AssertPageBelongsTo(page, spec); + foreach (var item in page.GetProperty("items").EnumerateArray()) + Assert.True(seen.Add(item.GetProperty(spec.IdProperty).ToString())); + cursor = page.GetProperty("next_cursor").GetString(); + } while (cursor is not null && seen.Count < spec.ExpectedIds.Length); + Assert.Equal(spec.ExpectedIds.Order(StringComparer.OrdinalIgnoreCase), + seen.Order(StringComparer.OrdinalIgnoreCase)); + Assert.Null(cursor); + } + + static void AssertPageBelongsTo(JsonElement page, ReadSpec spec) + { + foreach (var item in page.GetProperty("items").EnumerateArray()) + { + Assert.Equal(spec.TenantId.ToString(), item.GetProperty("tenant_id").GetString()); + if (item.TryGetProperty("application_id", out var applicationId)) + Assert.Equal(spec.ApplicationOrSubjectId.ToString(), applicationId.GetString()); + if (item.TryGetProperty("governed_record_id", out var governedRecordId)) + Assert.Equal(spec.ApplicationOrSubjectId.ToString(), governedRecordId.GetString()); + Assert.Contains(item.GetProperty(spec.IdProperty).ToString(), spec.ExpectedIds, + StringComparer.OrdinalIgnoreCase); + if (spec.Tool == "bdgrz.application.revision.list") + { + Assert.Equal($"Payroll {spec.Label}", item.GetProperty("name").GetString()); + Assert.Equal($"Run payroll {spec.Label}", item.GetProperty("purpose").GetString()); + } + else if (spec.Tool == "bdgrz.system_instance.list") + { + Assert.StartsWith($"Payroll {spec.Label} instance ", + item.GetProperty("name").GetString()); + Assert.StartsWith($"payroll-{spec.Label}-", + item.GetProperty("source_identifier").GetString()); + } + else + { + Assert.Equal($"Payroll {spec.Label}", item.GetProperty("subject").GetString()); + Assert.Equal($"Declared in scope for tenant {spec.Label}.", + item.GetProperty("rationale").GetString()); + } + } + } + + static void AssertExactBelongsTo(JsonElement view, Seed seed, + string idProperty, Guid expectedId) + { + Assert.Equal(seed.TenantId.ToString(), view.GetProperty("tenant_id").GetString()); + Assert.Equal(expectedId.ToString(), view.GetProperty(idProperty).GetString()); + Assert.Equal(seed.ApplicationId.ToString(), + view.GetProperty("application_id").GetString()); + if (idProperty == "application_id") + { + Assert.Equal($"Payroll {seed.Label}", view.GetProperty("name").GetString()); + Assert.Equal($"Run payroll {seed.Label}", view.GetProperty("purpose").GetString()); + } + else + { + Assert.Equal($"Payroll {seed.Label} instance 0", + view.GetProperty("name").GetString()); + Assert.Equal($"payroll-{seed.Label}-0", + view.GetProperty("source_identifier").GetString()); + } + } + + static async Task AssertHttpPreviewAsync(HttpClient owner, Seed seed, + Seed first, Seed second) + { + using var response = await owner.PostAsJsonAsync( + $"/api/v1/tenants/{seed.TenantId}/applications/{seed.ApplicationId}/change-previews", + new { expected_application_revision = 3, change_kind = "retire" }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + AssertPreviewBelongsTo(await ReadAsync(response), seed, first, second); + } + + static void AssertPreviewBelongsTo(JsonElement preview, Seed seed, + Seed first, Seed second) + { + Assert.Equal(seed.TenantId.ToString(), preview.GetProperty("tenant_id").GetString()); + Assert.Equal(seed.ApplicationId.ToString(), + preview.GetProperty("application_id").GetString()); + Assert.Equal(3, preview.GetProperty("application_revision").GetInt64()); + var references = preview.GetProperty("boundary_references").EnumerateArray().ToArray(); + Assert.Equal(seed.ApplicationBoundaries.Order(), references.Select(item => + Guid.Parse(item.GetProperty("boundary_id").GetString()!)).Order()); + Assert.All(references, item => + { + Assert.Equal(seed.TenantId.ToString(), item.GetProperty("tenant_id").GetString()); + Assert.Equal(seed.ApplicationId.ToString(), + item.GetProperty("governed_record_id").GetString()); + Assert.Equal($"Payroll {seed.Label}", item.GetProperty("subject").GetString()); + Assert.Equal($"Declared in scope for tenant {seed.Label}.", + item.GetProperty("rationale").GetString()); + }); + var foreign = seed.TenantId == first.TenantId ? second : first; + Assert.DoesNotContain(references, item => foreign.ApplicationBoundaries.Contains( + Guid.Parse(item.GetProperty("boundary_id").GetString()!))); + } + + static Dictionary PreviewArgs(Seed seed, Guid? tenantId = null) => new() + { + ["tenant_id"] = tenantId ?? seed.TenantId, + ["application_id"] = seed.ApplicationId, + ["expected_application_revision"] = 3, + ["change_kind"] = "retire", + }; + + static async Task AssertHttpDenialsAsync(HttpClient owner, HttpClient outsider, + Seed first, Seed second) + { + var firstPath = $"/api/v1/tenants/{first.TenantId}/applications/" + + first.ApplicationId; + var foreignPath = $"/api/v1/tenants/{second.TenantId}/applications/" + + first.ApplicationId; + foreach (var suffix in new[] + { + "/revisions/1", "/revisions", "/system-instances/" + first.Instances[0], + "/system-instances", "/boundary-references", + "/system-instances/" + first.Instances[0] + "/boundary-references", + }) + { + using var foreign = await owner.GetAsync(foreignPath + suffix); + using var denied = await outsider.GetAsync(firstPath + suffix); + Assert.Equal(HttpStatusCode.NotFound, foreign.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, denied.StatusCode); + } + using (var foreignInstance = await owner.GetAsync( + $"/api/v1/tenants/{second.TenantId}/applications/" + + $"{second.ApplicationId}/system-instances/{first.Instances[0]}")) + Assert.Equal(HttpStatusCode.NotFound, foreignInstance.StatusCode); + using (var foreignInstanceReferences = await owner.GetAsync( + $"/api/v1/tenants/{second.TenantId}/applications/" + + $"{second.ApplicationId}/system-instances/{first.Instances[0]}/boundary-references")) + Assert.Equal(HttpStatusCode.NotFound, foreignInstanceReferences.StatusCode); + using var foreignPreview = await owner.PostAsJsonAsync(foreignPath + "/change-previews", + new { expected_application_revision = 3, change_kind = "retire" }); + using var deniedPreview = await outsider.PostAsJsonAsync(firstPath + "/change-previews", + new { expected_application_revision = 3, change_kind = "retire" }); + Assert.Equal(HttpStatusCode.NotFound, foreignPreview.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, deniedPreview.StatusCode); + } + + static async Task PostUntilAuthorizedAsync(HttpClient client, string path, + object body) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(45); + while (DateTimeOffset.UtcNow < deadline) + { + using var response = await client.PostAsJsonAsync(path, body); + if (response.StatusCode == HttpStatusCode.OK) + return await ReadAsync(response); + Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden, + await response.Content.ReadAsStringAsync()); + await Task.Delay(250); + } + throw new TimeoutException($"POST {path} remained unauthorized after bootstrap."); + } + + static async Task WaitForOkAsync(Func> send) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(45); + while (DateTimeOffset.UtcNow < deadline) + { + using var response = await send(); + if (response.StatusCode == HttpStatusCode.OK) + return await ReadAsync(response); + Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Conflict, + await response.Content.ReadAsStringAsync()); + await Task.Delay(250); + } + throw new TimeoutException("The application read did not catch up before the deadline."); + } + + static async Task WaitForItemsAsync(HttpClient owner, string path, int count) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(45); + while (DateTimeOffset.UtcNow < deadline) + { + using var response = await owner.GetAsync(path); + if (response.StatusCode == HttpStatusCode.OK && + (await ReadAsync(response)).GetProperty("items").GetArrayLength() == count) + return; + Assert.True(response.StatusCode is HttpStatusCode.OK or HttpStatusCode.Conflict, + await response.Content.ReadAsStringAsync()); + await Task.Delay(250); + } + throw new TimeoutException($"GET {path} did not reach {count} items."); + } + + static async Task ReadAsync(HttpResponseMessage response) => + await response.Content.ReadFromJsonAsync(); + + sealed record Seed(string Label, Guid TenantId, Guid ApplicationId, Guid[] Instances, + Guid[] ApplicationBoundaries, Guid[] InstanceBoundaries); + + sealed record ReadSpec(string Path, string Tool, string IdProperty, string[] ExpectedIds, + string Label, Guid TenantId, Guid ApplicationOrSubjectId, Dictionary Args, + string? MinimumRevisionQuery = null); +}