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