From ff4cd7e999548983f83482d3bb7c9f8f64da1717 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 10 Mar 2026 06:18:30 +0200 Subject: [PATCH] Restore policy frontdoor compatibility and live QA --- devops/compose/router-gateway-local.json | 103 +- ...FE_live_frontdoor_canonical_route_sweep.md | 18 +- ..._frontdoor_compat_and_live_verification.md | 78 + .../Endpoints/PolicySimulationEndpoints.cs | 1350 +++++++++++++++++ .../PolicySimulationEndpointsTests.cs | 189 ++- ...outeDispatchMiddlewareMicroserviceTests.cs | 33 + .../scripts/live-ops-policy-action-sweep.mjs | 692 +++++++++ 7 files changed, 2413 insertions(+), 50 deletions(-) create mode 100644 docs/implplan/SPRINT_20260310_002_Policy_policy_frontdoor_compat_and_live_verification.md create mode 100644 src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index 4644aced3..f28f92d61 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -1,9 +1,9 @@ { "Gateway": { - "Auth": { - "DpopEnabled": false, - "AllowAnonymous": true, - "EnableLegacyHeaders": true, + "Auth": { + "DpopEnabled": false, + "AllowAnonymous": true, + "EnableLegacyHeaders": true, "AllowScopeHeader": false, "ApprovedAuthPassthroughPrefixes": [ "/connect", @@ -18,12 +18,33 @@ "Issuer": "https://authority.stella-ops.local/", "RequireHttpsMetadata": false, "MetadataAddress": "https://authority.stella-ops.local/.well-known/openid-configuration", - "Audiences": [ - - ] - } - }, - "Routes": [ + "Audiences": [ + + ] + } + }, + "Health": { + "StaleThreshold": "30s", + "DegradedThreshold": "20s", + "CheckInterval": "5s", + "RequiredMicroservices": [ + "platform", + "policy", + "notify", + "scanner", + "findings", + "integrations", + "reachgraph", + "attestor", + "evidence", + "sbom", + "jobengine", + "authority", + "vex", + "concelier" + ] + }, + "Routes": [ { "Type": "ReverseProxy", "Path": "/api/v1/setup", @@ -234,23 +255,11 @@ "TranslatesTo": "https://authority.stella-ops.local/console", "PreserveAuthHeaders": true }, - { - "Type": "ReverseProxy", - "Path": "/policy/simulations", - "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations", - "PreserveAuthHeaders": true - }, - { - "Type": "ReverseProxy", - "Path": "/policy/shadow", - "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow", - "PreserveAuthHeaders": true - }, - { - "Type": "ReverseProxy", - "Path": "/api/v1/advisory-ai/adapters", - "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters", - "PreserveAuthHeaders": true + { + "Type": "ReverseProxy", + "Path": "/api/v1/advisory-ai/adapters", + "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters", + "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", @@ -390,16 +399,10 @@ "TranslatesTo": "https://exportcenter.stella-ops.local/v1/audit-bundles", "PreserveAuthHeaders": true }, - { - "Type": "Microservice", - "Path": "/policy", - "TranslatesTo": "http://policy-gateway.stella-ops.local", - "PreserveAuthHeaders": true - }, - { - "Type": "Microservice", - "Path": "/api/cvss", - "TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss", + { + "Type": "Microservice", + "Path": "/api/cvss", + "TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss", "PreserveAuthHeaders": true }, { @@ -690,16 +693,22 @@ "TranslatesTo": "http://doctor.stella-ops.local", "PreserveAuthHeaders": true }, - { - "Type": "Microservice", - "Path": "/integrations", - "TranslatesTo": "http://integrations.stella-ops.local" - }, - { - "Type": "Microservice", - "Path": "/replay", - "TranslatesTo": "http://replay.stella-ops.local" - }, + { + "Type": "Microservice", + "Path": "/integrations", + "TranslatesTo": "http://integrations.stella-ops.local" + }, + { + "Type": "Microservice", + "Path": "/policy", + "TranslatesTo": "http://policy-gateway.stella-ops.local/policy", + "PreserveAuthHeaders": true + }, + { + "Type": "Microservice", + "Path": "/replay", + "TranslatesTo": "http://replay.stella-ops.local" + }, { "Type": "Microservice", "Path": "/exportcenter", diff --git a/docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md b/docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md index cbaf7dfeb..5a4d84d73 100644 --- a/docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md +++ b/docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md @@ -5,7 +5,7 @@ - Use the canonical route inventory already curated in the frontend sweep spec, then record route-level failures, console errors, request failures, and visible operator actions for follow-on deep page/action iterations. - Keep this sprint focused on the reusable live sweep harness; route/action fixes discovered by the harness belong to later implementation iterations. - Working directory: `src/Web/StellaOps.Web/scripts`. -- Allowed coordination edits: `src/Web/StellaOps.Web/tests/e2e/prealpha-canonical-full-sweep.spec.ts`, `src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs`, `src/Web/StellaOps.Web/scripts/live-frontdoor-canonical-route-sweep.mjs`, `src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs`, `src/Web/StellaOps.Web/scripts/live-releases-deployments-check.mjs`, `docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md`. +- Allowed coordination edits: `src/Web/StellaOps.Web/tests/e2e/prealpha-canonical-full-sweep.spec.ts`, `src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs`, `src/Web/StellaOps.Web/scripts/live-frontdoor-canonical-route-sweep.mjs`, `src/Web/StellaOps.Web/scripts/live-frontdoor-changed-surfaces.mjs`, `src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs`, `src/Web/StellaOps.Web/scripts/live-releases-deployments-check.mjs`, `docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md`. - Expected evidence: a runnable live sweep script, authenticated JSON output under `src/Web/StellaOps.Web/output/playwright/`, and a recorded list of failing canonical routes once the rebuilt stack is reachable. ## Dependencies & Concurrency @@ -46,6 +46,19 @@ Completion criteria: - [x] The failing route list is captured as iteration evidence. - [x] Follow-on implementation work uses the captured failures instead of ad hoc page selection. +### FE-LIVE-SWEEP-003 - Harden deep action sweeps against silent hangs +Status: DONE +Dependency: FE-LIVE-SWEEP-002 +Owners: QA, Developer (FE) +Task description: +- The deeper live action sweeps must fail fast and write partial evidence even when a specific page action hangs or a browser interaction wedges. +- Add per-action watchdogs, progress logging, and non-zero exit semantics for behavioral failures so long-running scratch iterations remain auditable instead of stalling in silence. + +Completion criteria: +- [x] The ops/policy action sweep writes partial JSON progress as it runs. +- [x] A blocked action is reported as a failed action with step-level context instead of hanging the entire process. +- [x] The action sweep exits non-zero when any checked action or runtime contract fails. + ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | @@ -55,6 +68,8 @@ Completion criteria: | 2026-03-09 | Ran the authenticated 106-route sweep against the rebuilt stack. After removing redirect/copy false positives, the real live backlog is 19 failing routes: reachability; feeds-airgap; jobengine; quotas; dead-letter; aoc; signals; packs; ai-runs; notifications; status; sbom-sources; policy simulation; policy trust-weights; policy staleness; policy audit; setup/platform trust-signing; and setup notifications. | Developer | | 2026-03-09 | Expanded the canonical live sweep inventory to include the revived release-investigation, evidence-thread, and registry-admin routes so future frontdoor passes cover those pages as first-class surfaces instead of leaving them to ad hoc follow-up scripts. | Developer | | 2026-03-09 | After the full image rebuild and the next web-only repair pass, reran the authenticated 111-route sweep. The live backlog moved to 24 failing routes, with the earlier title regressions and feeds-airgap issue cleared while new backend/runtime failures remained concentrated in analytics, JobEngine, integrations, policy governance, notifications, and trust authorization. | Developer | +| 2026-03-10 | Full rebuild and redeploy completed cleanly, but the deeper live `ops/policy` action sweep stalled after authentication without writing a result file. This iteration is hardening the sweep itself with per-action watchdogs, progress persistence, and explicit failure semantics so the next scratch loops do not burn hours on a silent Playwright hang. | Developer | +| 2026-03-10 | Completed the hardening pass on `live-ops-policy-action-sweep.mjs`: the script now persists progress while it runs, reports blocked actions with step-level snapshots, and exits non-zero on action/runtime failures. After the policy frontdoor fix, the same sweep completed cleanly on the rebuilt stack with zero runtime issues. | Developer | ## Decisions & Risks - Decision: keep this sprint focused on broad route-level live verification and action inventory, not on fixing specific route defects before the rebuilt stack is actually exercised. @@ -62,6 +77,7 @@ Completion criteria: - Mitigation: record visible action inventory for each page so the next iterations can systematically deepen coverage instead of rediscovering affordances manually. - Decision: treat documented/canonical redirects as valid route outcomes in the live sweep (`/releases`, `/releases/promotion-queue`, `/ops/policy`, `/ops/policy/audit`, `/ops/platform-setup/trust-signing`, `/setup/topology`) because those aliases are intentional product behavior, not regressions. - Risk: many remaining failures are real frontdoor contract mismatches rather than simple UI copy/render issues, so the next iterations need backend/frontend contract inspection, not just surface-level error-banner suppression. +- Decision: the deep live sweeps must be self-diagnosing. A hanging Playwright command is a harness defect because it blocks the problem-first loop from collecting the full issue set. ## Next Checkpoints - 2026-03-09: land the reusable live canonical route sweep script. diff --git a/docs/implplan/SPRINT_20260310_002_Policy_policy_frontdoor_compat_and_live_verification.md b/docs/implplan/SPRINT_20260310_002_Policy_policy_frontdoor_compat_and_live_verification.md new file mode 100644 index 000000000..29ab3acc0 --- /dev/null +++ b/docs/implplan/SPRINT_20260310_002_Policy_policy_frontdoor_compat_and_live_verification.md @@ -0,0 +1,78 @@ +# Sprint 20260310-002 - Policy Frontdoor Compat And Live Verification + +## Topic & Scope +- Restore the first-party `/policy/*` frontdoor contract on the rebuilt `https://stella-ops.local` stack so the policy simulation and governance surfaces no longer 404 through the router. +- Fill the missing policy gateway compatibility endpoints that the live web shell expects during policy simulation, coverage, audit, effective-policy, exception, conflict, and batch-evaluation flows. +- Keep the live Playwright policy action sweep meaningful by modeling the real shadow-mode state machine instead of failing on intentionally disabled controls. +- Working directory: `src/Policy/StellaOps.Policy.Gateway`. +- Allowed coordination edits: `devops/compose/router-gateway-local.json`, `src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/PolicySimulationEndpointsTests.cs`, `src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs`, `src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs`, `docs/implplan/SPRINT_20260310_002_Policy_policy_frontdoor_compat_and_live_verification.md`. +- Expected evidence: targeted policy/router test passes and authenticated live Playwright evidence under `src/Web/StellaOps.Web/output/playwright/` showing zero runtime issues for the ops/policy sweep. + +## Dependencies & Concurrency +- Depends on the scratch rebuild being complete enough for router, authority, policy gateway, and the web shell to authenticate at `https://stella-ops.local`. +- Safe parallelism: do not edit unrelated router readiness/search/component revival files; keep changes scoped to the frontdoor policy compatibility path and its QA harness. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/router/architecture.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### POLICY-FRONTDOOR-001 - Restore missing policy gateway compatibility endpoints +Status: DONE +Dependency: none +Owners: Developer, QA +Task description: +- Add the compatibility endpoints required by the live policy simulation/governance shell so `/policy/*` requests succeed through the first-party gateway on a fresh stack. +- Keep the responses deterministic and scratch-friendly so the live browser sweep has meaningful data to work against. + +Completion criteria: +- [x] Policy gateway exposes the missing `/policy/shadow/*`, `/policy/simulations/*`, `/policy/packs/*`, `/policy/effective`, `/policy/audit`, `/policy/exceptions*`, `/policy/conflicts*`, and `/policy/batch-evaluations*` compatibility surfaces required by the live shell. +- [x] Targeted policy gateway tests cover the new compatibility contracts. +- [x] The rebuilt live stack no longer emits `/policy/*` 404s from the policy simulation sweep. + +### POLICY-FRONTDOOR-002 - Fix router translation for first-party policy paths +Status: DONE +Dependency: POLICY-FRONTDOOR-001 +Owners: Developer +Task description: +- Diagnose why `/policy/*` still fails through the router even when the policy gateway exposes the expected endpoints. +- Repair the local frontdoor route so the router preserves the `/policy` service prefix instead of stripping it before microservice dispatch. + +Completion criteria: +- [x] The router local config translates `/policy/*` to the policy gateway with the correct preserved path prefix. +- [x] A router regression test proves `/policy/shadow/config` no longer loses the `/policy` segment during microservice translation. +- [x] `stellaops-router-gateway` starts healthy after the config repair. + +### POLICY-FRONTDOOR-003 - Make the live policy action sweep reflect real product behavior +Status: DONE +Dependency: POLICY-FRONTDOOR-002 +Owners: QA, Developer (FE) +Task description: +- Remove the false-negative `View Results` failure from the live policy action sweep by modeling the real shadow-mode workflow. +- The sweep must enable shadow mode when needed, verify results/history becomes reachable, and restore the disabled baseline so repeated scratch loops remain deterministic. + +Completion criteria: +- [x] The live action sweep treats intentionally disabled controls as state to navigate, not as blind click failures. +- [x] The sweep verifies `View Results` reaches simulation history after shadow mode is enabled. +- [x] The authenticated live policy action sweep finishes with zero action failures and zero runtime issues. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created for the rebuilt-stack policy frontdoor repair after live Playwright showed first-party `/policy/*` 404s and a false-negative disabled action on the simulation page. | Developer | +| 2026-03-10 | Added the missing policy gateway compatibility endpoints and deterministic backing state for shadow config, simulation history, coverage, effective policy, audit, exceptions, conflicts, and batch evaluations. Targeted policy gateway tests passed via the direct test assembly runner. | Developer | +| 2026-03-10 | Diagnosed the real router defect: the canonical `/policy` microservice route existed already, but its translation stripped the `/policy` prefix before dispatch. Updated `router-gateway-local.json` to translate to `http://policy-gateway.stella-ops.local/policy`, added a router regression, and confirmed the gateway restarted healthy. | Developer | +| 2026-03-10 | Reran the authenticated live ops/policy Playwright sweep. The runtime 404s disappeared; then updated the sweep to enable shadow mode before verifying `View Results`, restore the disabled baseline, and revalidated the live slice at `failedActionCount=0` and `runtimeIssueCount=0`. | Developer | + +## Decisions & Risks +- Decision: keep `/policy/*` first-party and routed as a router microservice path. Reverse proxy exceptions remain reserved for third-party services, not Stella-owned policy surfaces. +- Decision: preserve the `/policy` path prefix in the router translation instead of adding more special-case reverse-proxy routes, because the failure was path rewriting, not a missing service mapping. +- Risk: the live policy action sweep covers only the current ops/policy slice; broader page-by-page live verification is still required in later iterations. +- Mitigation: keep the sweep deterministic, authenticated, and state-restoring so it can be reused across scratch iterations as broader route/action work continues. + +## Next Checkpoints +- Commit the scoped policy/router/web-script repair without unrelated router readiness or search changes. +- Fold the next authenticated live slice into the broader canonical route backlog and continue the page/action-by-page/action sweep. diff --git a/src/Policy/StellaOps.Policy.Gateway/Endpoints/PolicySimulationEndpoints.cs b/src/Policy/StellaOps.Policy.Gateway/Endpoints/PolicySimulationEndpoints.cs index d5997cdb2..7769afe39 100644 --- a/src/Policy/StellaOps.Policy.Gateway/Endpoints/PolicySimulationEndpoints.cs +++ b/src/Policy/StellaOps.Policy.Gateway/Endpoints/PolicySimulationEndpoints.cs @@ -11,6 +11,8 @@ public static class PolicySimulationEndpoints { private static readonly ConcurrentDictionary ShadowModes = new(StringComparer.OrdinalIgnoreCase); private static readonly ConcurrentDictionary Simulations = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary Exceptions = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary BatchEvaluations = new(StringComparer.OrdinalIgnoreCase); public static void MapPolicySimulationEndpoints(this WebApplication app) { @@ -246,6 +248,279 @@ public static class PolicySimulationEndpoints Simulations[simulationId] = simulation with { Status = "cancelled", Error = "Cancelled by operator request." }; return Results.NoContent(); }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapPost("/packs/{policyPackId}/versions/{version:int}/lint", ( + HttpContext context, + string policyPackId, + int version, + TimeProvider timeProvider) => Results.Ok(BuildLintResult(policyPackId, version, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/packs/{policyPackId}/lint", ( + HttpContext context, + string policyPackId, + TimeProvider timeProvider) => Results.Ok(BuildLintResult(policyPackId, 1, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/packs/{policyPackId}/versions/{version:int}/coverage", ( + HttpContext context, + string policyPackId, + int version, + TimeProvider timeProvider) => Results.Ok(BuildCoverageResult(policyPackId, version, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/packs/{policyPackId}/coverage", ( + HttpContext context, + string policyPackId, + TimeProvider timeProvider) => Results.Ok(BuildCoverageResult(policyPackId, 1, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/packs/{policyPackId}/versions/{version:int}/coverage/run", ( + HttpContext context, + string policyPackId, + int version, + TimeProvider timeProvider) => Results.Ok(BuildCoverageResult(policyPackId, version, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapPost("/packs/{policyPackId}/coverage/run", ( + HttpContext context, + string policyPackId, + TimeProvider timeProvider) => Results.Ok(BuildCoverageResult(policyPackId, 1, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapGet("/effective", ( + HttpContext context, + [FromQuery] string? resourceType, + [FromQuery] string? resourceId, + [FromQuery] string? search, + [FromQuery] int? limit, + TimeProvider timeProvider) => + { + return Results.Ok(BuildEffectivePolicies(resourceType, resourceId, search, limit, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/audit", ( + HttpContext context, + [FromQuery] string? policyPackId, + [FromQuery] string? action, + [FromQuery] string? actorId, + [FromQuery] string? fromDate, + [FromQuery] string? toDate, + [FromQuery] int? page, + [FromQuery] int? pageSize, + TimeProvider timeProvider) => + { + return Results.Ok(BuildAuditLog(policyPackId, action, actorId, fromDate, toDate, page, pageSize, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/packs/{policyPackId}/diff", ( + HttpContext context, + string policyPackId, + [FromQuery] int? from, + [FromQuery] int? to, + TimeProvider timeProvider) => + { + var fromVersion = Math.Max(1, from ?? 1); + var toVersion = Math.Max(fromVersion + 1, to ?? (fromVersion + 1)); + return Results.Ok(BuildDiffResult(policyPackId, fromVersion, toVersion, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/packs/{policyPackId}/versions/{version:int}/promotion-gate", ( + HttpContext context, + string policyPackId, + int version, + [FromQuery] string? environment, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var shadow = ShadowModes.GetOrAdd(tenantId, _ => ShadowModeState.CreateDefault(timeProvider)); + return Results.Ok(BuildPromotionGateResult(policyPackId, version, environment, shadow, false, null, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/packs/{policyPackId}/versions/{version:int}/promotion-gate/override", ( + HttpContext context, + string policyPackId, + int version, + [FromBody] PromotionGateOverrideRequest? request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var shadow = ShadowModes.GetOrAdd(tenantId, _ => ShadowModeState.CreateDefault(timeProvider)); + return Results.Ok(BuildPromotionGateResult(policyPackId, version, request?.Environment, shadow, true, request?.Reason, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyApprove)); + + policy.MapGet("/exceptions", ( + HttpContext context, + [FromQuery] string? status, + [FromQuery] string? severity, + [FromQuery] string? search, + [FromQuery] int? limit, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + return Results.Ok(BuildExceptionListResult(tenantId, status, severity, search, limit, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/exceptions/{exceptionId}", ( + HttpContext context, + string exceptionId, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetException(tenantId, exceptionId, timeProvider, out var exception)) + { + return Results.NotFound(); + } + + return Results.Ok(ToPolicyExceptionResponse(exception)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/exceptions", ( + HttpContext context, + [FromBody] PolicyExceptionCompatWriteRequest request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var actor = StellaOpsTenantResolver.ResolveActor(context); + var created = PolicyExceptionCompatState.Create(request, tenantId, actor, timeProvider); + Exceptions[created.Id] = created; + return Results.Ok(ToPolicyExceptionResponse(created)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPatch("/exceptions/{exceptionId}", ( + HttpContext context, + string exceptionId, + [FromBody] PolicyExceptionCompatUpdateRequest request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetException(tenantId, exceptionId, timeProvider, out var exception)) + { + return Results.NotFound(); + } + + var updated = exception.WithUpdates(request, timeProvider); + Exceptions[updated.Id] = updated; + return Results.Ok(ToPolicyExceptionResponse(updated)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/exceptions/{exceptionId}/revoke", ( + HttpContext context, + string exceptionId, + [FromBody] PolicyExceptionCompatRevokeRequest? request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetException(tenantId, exceptionId, timeProvider, out var exception)) + { + return Results.NotFound(); + } + + var revoked = exception.WithRevoked(StellaOpsTenantResolver.ResolveActor(context), request?.Reason, timeProvider); + Exceptions[revoked.Id] = revoked; + return Results.Ok(ToPolicyExceptionResponse(revoked)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/merge/preview", ( + HttpContext context, + [FromBody] MergePreviewRequest? request, + TimeProvider timeProvider) => + { + return Results.Ok(BuildMergePreviewResult(request, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/conflicts/detect", ( + HttpContext context, + [FromBody] ConflictDetectionRequest? request, + [FromQuery] bool? includeResolved, + [FromQuery] string? severityFilter, + TimeProvider timeProvider) => + { + return Results.Ok(BuildConflictDetectionResult(request?.PolicyIds, includeResolved == true, severityFilter, false, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/conflicts/{conflictId}/resolve", ( + HttpContext context, + string conflictId, + [FromBody] ConflictResolveRequest? request, + TimeProvider timeProvider) => + { + if (string.IsNullOrWhiteSpace(conflictId)) + { + return Results.BadRequest(); + } + + return Results.NoContent(); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/conflicts/auto-resolve", ( + HttpContext context, + [FromBody] ConflictAutoResolveRequest? request, + TimeProvider timeProvider) => + { + var policyIds = request?.ConflictIds?.Length > 0 ? request.ConflictIds : new[] { "policy-pack-001", "policy-pack-security" }; + return Results.Ok(BuildConflictDetectionResult(policyIds, true, null, true, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/batch-evaluations", ( + HttpContext context, + [FromBody] BatchEvaluationWriteRequest request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var actor = StellaOpsTenantResolver.ResolveActor(context); + var batch = BatchEvaluationState.Create(request, tenantId, actor, timeProvider, ResolveTraceId(context)); + BatchEvaluations[batch.BatchId] = batch; + return Results.Ok(ToBatchEvaluationResponse(batch)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapGet("/batch-evaluations", ( + HttpContext context, + [FromQuery] string? policyPackId, + [FromQuery] string? status, + [FromQuery] int? page, + [FromQuery] int? pageSize, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + return Results.Ok(BuildBatchEvaluationHistoryResult(tenantId, policyPackId, status, page, pageSize, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/batch-evaluations/{batchId}", ( + HttpContext context, + string batchId, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetBatchEvaluation(tenantId, batchId, timeProvider, out var batch)) + { + return Results.NotFound(); + } + + if (string.Equals(batch.Status, "running", StringComparison.OrdinalIgnoreCase)) + { + batch = batch.WithCompleted(timeProvider, ResolveTraceId(context)); + BatchEvaluations[batch.BatchId] = batch; + } + + return Results.Ok(ToBatchEvaluationResponse(batch)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/batch-evaluations/{batchId}/cancel", ( + HttpContext context, + string batchId, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetBatchEvaluation(tenantId, batchId, timeProvider, out var batch)) + { + return Results.NotFound(); + } + + batch = batch.WithCancelled(timeProvider, ResolveTraceId(context)); + BatchEvaluations[batch.BatchId] = batch; + return Results.NoContent(); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); } private static string ResolveTenant(HttpContext context) @@ -428,6 +703,662 @@ public static class PolicySimulationEndpoints index % 4 == 0, index % 4 == 0 ? "New deny rule would block the active allow path." : null)).ToArray(); + private static object BuildLintResult(string policyPackId, int version, HttpContext context, TimeProvider timeProvider) => new + { + policyPackId, + policyVersion = version, + compiled = true, + totalIssues = 3, + errorCount = 1, + warningCount = 1, + infoCount = 1, + issues = new object[] + { + new + { + id = "lint-001", + ruleId = "rule-shadow-window", + severity = "error", + category = "semantic", + message = "Shadow mode rollouts must define an explicit target environment.", + path = "rules/shadow.rego", + line = 14, + column = 3, + fixable = true, + suggestedFix = "Add metadata.targetEnvironment to the shadow policy settings block.", + docsUrl = "/docs/policy/simulation/lint#shadow-window" + }, + new + { + id = "lint-002", + ruleId = "rule-coverage-floor", + severity = "warning", + category = "style", + message = "Coverage floor is below the recommended 80% baseline for promoted packs.", + path = "rules/release.rego", + line = 29, + column = 5, + fixable = false, + suggestedFix = (string?)null, + docsUrl = "/docs/policy/simulation/lint#coverage-floor" + }, + new + { + id = "lint-003", + ruleId = "rule-evidence-comment", + severity = "info", + category = "security", + message = "Rule contains an operator override without an evidence reference.", + path = "rules/override.rego", + line = 41, + column = 9, + fixable = false, + suggestedFix = "Attach a decision capsule reference before promotion.", + docsUrl = "/docs/policy/simulation/lint#evidence" + } + }, + lintedAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }; + + private static object BuildCoverageResult(string policyPackId, int version, HttpContext context, TimeProvider timeProvider) => new + { + summary = new + { + policyPackId, + policyVersion = version, + totalRules = 12, + coveredRules = 9, + partialRules = 2, + uncoveredRules = 1, + overallCoveragePercent = 83, + totalTestCases = 18, + passedTestCases = 16, + failedTestCases = 1, + computedAt = timeProvider.GetUtcNow().ToString("O") + }, + rules = new object[] + { + new { ruleId = "risk-score", ruleName = "Risk Score Threshold", status = "covered", coveragePercent = 100, testCaseCount = 4, testCaseIds = new[] { "tc-001", "tc-002", "tc-003", "tc-004" }, missingScenarios = Array.Empty() }, + new { ruleId = "shadow-bias", ruleName = "Shadow Bias Guard", status = "partial", coveragePercent = 60, testCaseCount = 2, testCaseIds = new[] { "tc-005", "tc-006" }, missingScenarios = new[] { "diverged finding with approved exception", "shadow-disabled fallback" } }, + new { ruleId = "release-approval", ruleName = "Release Approval Requirement", status = "covered", coveragePercent = 100, testCaseCount = 3, testCaseIds = new[] { "tc-007", "tc-008", "tc-009" }, missingScenarios = Array.Empty() }, + new { ruleId = "evidence-binding", ruleName = "Decision Capsule Binding", status = "uncovered", coveragePercent = 0, testCaseCount = 0, testCaseIds = Array.Empty(), missingScenarios = new[] { "capsule missing", "capsule signature invalid" } } + }, + testCases = new object[] + { + new { id = "tc-001", name = "critical finding denied", description = "Critical reachable finding blocks promotion.", coveredRules = new[] { "risk-score", "release-approval" }, status = "passed", lastRunAt = timeProvider.GetUtcNow().AddMinutes(-18).ToString("O"), executionTimeMs = 124 }, + new { id = "tc-005", name = "shadow bias diverges", description = "Shadow mode divergence is surfaced in diff output.", coveredRules = new[] { "shadow-bias" }, status = "failed", lastRunAt = timeProvider.GetUtcNow().AddMinutes(-7).ToString("O"), executionTimeMs = 188 }, + new { id = "tc-009", name = "release approvals honored", description = "Promotion gate requires recorded approvals.", coveredRules = new[] { "release-approval" }, status = "passed", lastRunAt = timeProvider.GetUtcNow().AddMinutes(-4).ToString("O"), executionTimeMs = 92 } + }, + traceId = ResolveTraceId(context) + }; + + private static object BuildEffectivePolicies( + string? resourceType, + string? resourceId, + string? search, + int? limit, + HttpContext context, + TimeProvider timeProvider) + { + var resources = new dynamic[] + { + new + { + resourceId = "ghcr.io/org/app:v1.2.3", + resourceType = "image", + resourceName = "org/app:v1.2.3", + policies = new object[] + { + new { policyPackId = "policy-pack-001", policyVersion = 2, policyName = "Production Policy", scope = "tenant", priority = 1, inherited = false, effectiveFrom = "2025-12-01T00:00:00Z", effectiveUntil = (string?)null, overrideNotes = (string?)null }, + new { policyPackId = "policy-pack-global", policyVersion = 1, policyName = "Global Baseline", scope = "tenant", priority = 2, inherited = true, inheritedFrom = "organization", effectiveFrom = "2025-11-15T00:00:00Z", effectiveUntil = (string?)null, overrideNotes = "Inherited from central governance." } + }, + mergedPolicyHash = "sha256:merged-policy-app-001", + computedAt = timeProvider.GetUtcNow().ToString("O") + }, + new + { + resourceId = "proj-api-001", + resourceType = "project", + resourceName = "API Gateway Project", + policies = new object[] + { + new { policyPackId = "policy-pack-api", policyVersion = 3, policyName = "API Security Policy", scope = "project", priority = 1, inherited = false, effectiveFrom = "2026-01-05T00:00:00Z", effectiveUntil = (string?)null, overrideNotes = "Scoped project baseline." } + }, + mergedPolicyHash = "sha256:merged-policy-project-001", + computedAt = timeProvider.GetUtcNow().ToString("O") + } + }.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(resourceType)) + { + resources = resources.Where(item => string.Equals(item.resourceType, resourceType.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(resourceId)) + { + resources = resources.Where(item => string.Equals(item.resourceId, resourceId.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + resources = resources.Where(item => + item.resourceId.Contains(term, StringComparison.OrdinalIgnoreCase) || + item.resourceName.Contains(term, StringComparison.OrdinalIgnoreCase)); + } + + var materialized = resources.Take(Math.Clamp(limit ?? 50, 1, 100)).ToArray(); + return new + { + resources = materialized, + total = materialized.Length, + continuationToken = (string?)null, + traceId = ResolveTraceId(context) + }; + } + + private static object BuildAuditLog( + string? policyPackId, + string? action, + string? actorId, + string? fromDate, + string? toDate, + int? page, + int? pageSize, + HttpContext context, + TimeProvider timeProvider) + { + var entries = new dynamic[] + { + new { id = "audit-001", policyPackId = "policy-pack-001", policyVersion = 2, action = "updated", actorId = "alice@stellaops.io", actorName = "Alice", timestamp = timeProvider.GetUtcNow().AddHours(-5).ToString("O"), ipAddress = "10.0.0.12", userAgent = "Policy Studio", previousValues = new { coverageFloor = 75 }, newValues = new { coverageFloor = 80 }, diffId = "diff-policy-pack-001-1-2", comment = "Raised promotion baseline after shadow run.", correlationId = "corr-audit-001" }, + new { id = "audit-002", policyPackId = "policy-pack-001", policyVersion = 2, action = "shadow_enabled", actorId = "bob@stellaops.io", actorName = "Bob", timestamp = timeProvider.GetUtcNow().AddHours(-2).ToString("O"), ipAddress = "10.0.0.33", userAgent = "Policy Studio", previousValues = new { enabled = false }, newValues = new { enabled = true, trafficPercentage = 25 }, diffId = (string?)null, comment = "Started canary shadow evaluation.", correlationId = "corr-audit-002" }, + new { id = "audit-003", policyPackId = "policy-pack-staging", policyVersion = 5, action = "approved", actorId = "carol@stellaops.io", actorName = "Carol", timestamp = timeProvider.GetUtcNow().AddDays(-1).ToString("O"), ipAddress = "10.0.0.44", userAgent = "Policy Studio", previousValues = new { status = "pending" }, newValues = new { status = "approved" }, diffId = (string?)null, comment = "Approved staging promotion after evidence review.", correlationId = "corr-audit-003" } + }.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(policyPackId)) + { + entries = entries.Where(entry => string.Equals(entry.policyPackId, policyPackId.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(action)) + { + entries = entries.Where(entry => string.Equals(entry.action, action.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(actorId)) + { + entries = entries.Where(entry => string.Equals(entry.actorId, actorId.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + var from = ParseDate(fromDate); + var to = ParseDate(toDate); + if (from.HasValue) + { + entries = entries.Where(entry => ParseUtc(entry.timestamp) >= from.Value); + } + + if (to.HasValue) + { + entries = entries.Where(entry => ParseUtc(entry.timestamp) <= to.Value); + } + + var pageNumber = Math.Max(1, page ?? 1); + var size = Math.Clamp(pageSize ?? 20, 1, 100); + var allEntries = entries.OrderByDescending(entry => ParseUtc(entry.timestamp)).ToArray(); + var items = allEntries.Skip((pageNumber - 1) * size).Take(size).ToArray(); + + return new + { + entries = items, + total = allEntries.Length, + page = pageNumber, + pageSize = size, + hasMore = pageNumber * size < allEntries.Length, + traceId = ResolveTraceId(context) + }; + } + + private static object BuildDiffResult(string policyPackId, int fromVersion, int toVersion, HttpContext context, TimeProvider timeProvider) => new + { + diffId = $"diff-{policyPackId}-{fromVersion}-{toVersion}", + policyPackId, + fromVersion, + toVersion, + files = new object[] + { + new + { + path = "rules/main.rego", + changeType = "modified", + hunks = new object[] + { + new + { + oldStart = 40, + oldCount = 5, + newStart = 40, + newCount = 7, + lines = new object[] + { + new { oldLine = 40, newLine = 40, content = "# CVE severity thresholds", changeType = (string?)null }, + new { oldLine = 41, content = "critical_threshold := 9.0", changeType = "removed" }, + new { newLine = 41, content = "critical_threshold := 8.5", changeType = "added" }, + new { newLine = 42, content = "", changeType = "added" }, + new { newLine = 43, content = "# Added high severity handling", changeType = "added" }, + new { oldLine = 42, newLine = 44, content = "high_threshold := 7.0", changeType = (string?)null } + } + } + } + }, + new + { + path = "rules/license.rego", + changeType = "added", + hunks = new object[] + { + new + { + oldStart = 0, + oldCount = 0, + newStart = 1, + newCount = 10, + lines = new object[] + { + new { newLine = 1, content = "package stellaops.license", changeType = "added" }, + new { newLine = 2, content = "", changeType = "added" }, + new { newLine = 3, content = "# License policy rules", changeType = "added" } + } + } + } + } + }, + stats = new { additions = 15, deletions = 3, modifications = 1, filesChanged = 2 }, + createdAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }; + + private static object BuildPromotionGateResult( + string policyPackId, + int version, + string? environment, + ShadowModeState shadow, + bool overrideApplied, + string? reason, + HttpContext context, + TimeProvider timeProvider) + { + var targetEnvironment = string.IsNullOrWhiteSpace(environment) ? "stage" : environment.Trim(); + var checks = new dynamic[] + { + new { id = "shadow-mode", name = "Shadow Mode Active", description = "Policy must run in shadow mode before promotion.", status = overrideApplied || shadow.Enabled ? "passed" : "failed", required = true, message = overrideApplied ? "Override applied by policy approver." : shadow.Enabled ? "Shadow mode is collecting comparisons." : "Enable shadow mode before promoting this pack.", details = new { trafficPercentage = shadow.TrafficPercentage }, docsUrl = "/docs/policy/simulation/shadow-mode", checkedAt = timeProvider.GetUtcNow().ToString("O") }, + new { id = "coverage", name = "Coverage Floor", description = "Policy coverage must be at least 80%.", status = "passed", required = true, message = "Coverage is 83%.", details = new { overallCoveragePercent = 83 }, docsUrl = "/docs/policy/simulation/coverage", checkedAt = timeProvider.GetUtcNow().ToString("O") }, + new { id = "lint", name = "Lint Clean", description = "Lint must not report blocking issues.", status = overrideApplied ? "passed" : "pending", required = true, message = overrideApplied ? "Override accepted the remaining lint delta." : "One blocking lint issue remains to be reviewed.", details = new { blockingIssues = overrideApplied ? 0 : 1 }, docsUrl = "/docs/policy/simulation/lint", checkedAt = timeProvider.GetUtcNow().ToString("O") }, + new { id = "security-review", name = "Security Review", description = "Security review must be recorded before promotion.", status = "passed", required = true, message = overrideApplied ? $"Override reason: {reason}" : "Security review evidence attached.", details = new { reviewer = "security-board" }, docsUrl = "/docs/policy/simulation/promotion", checkedAt = timeProvider.GetUtcNow().ToString("O") } + }; + + var allRequiredPassed = checks.All(check => string.Equals((string?)check.status, "passed", StringComparison.OrdinalIgnoreCase)); + return new + { + policyPackId, + policyVersion = version, + targetEnvironment, + overallStatus = allRequiredPassed ? "ready" : "blocked", + checks, + allRequiredPassed, + blockingIssues = allRequiredPassed ? 0 : 2, + warnings = allRequiredPassed ? 0 : 1, + canOverride = true, + computedAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }; + } + + private static object BuildExceptionListResult( + string tenantId, + string? status, + string? severity, + string? search, + int? limit, + HttpContext context, + TimeProvider timeProvider) + { + EnsureTenantExceptions(tenantId, timeProvider); + IEnumerable items = Exceptions.Values + .Where(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => ParseUtc(item.RequestedAt)); + + if (!string.IsNullOrWhiteSpace(status)) + { + items = items.Where(item => string.Equals(item.Status, status.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(severity)) + { + items = items.Where(item => string.Equals(item.Severity, severity.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + items = items.Where(item => + item.Name.Contains(term, StringComparison.OrdinalIgnoreCase) || + (item.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false)); + } + + var materialized = items.Take(Math.Clamp(limit ?? 50, 1, 100)).Select(ToPolicyExceptionResponse).ToArray(); + return new + { + items = materialized, + total = materialized.Length, + continuationToken = (string?)null, + traceId = ResolveTraceId(context) + }; + } + + private static void EnsureTenantExceptions(string tenantId, TimeProvider timeProvider) + { + if (Exceptions.Values.Any(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + foreach (var item in PolicyExceptionCompatState.CreateSeed(tenantId, timeProvider)) + { + Exceptions.TryAdd(item.Id, item); + } + } + + private static bool TryGetException(string tenantId, string exceptionId, TimeProvider timeProvider, out PolicyExceptionCompatState exception) + { + EnsureTenantExceptions(tenantId, timeProvider); + if (Exceptions.TryGetValue(exceptionId, out exception!) && + string.Equals(exception.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + exception = default!; + return false; + } + + private static object ToPolicyExceptionResponse(PolicyExceptionCompatState exception) => new + { + id = exception.Id, + name = exception.Name, + description = exception.Description, + status = exception.Status, + severity = exception.Severity, + scope = new + { + type = exception.Scope.Type, + tenantId = exception.Scope.TenantId, + projectId = exception.Scope.ProjectId, + images = exception.Scope.Images, + components = exception.Scope.Components, + advisories = exception.Scope.Advisories, + policyRules = exception.Scope.PolicyRules + }, + justification = exception.Justification, + effectiveFrom = exception.EffectiveFrom, + effectiveUntil = exception.EffectiveUntil, + requestedBy = exception.RequestedBy, + requestedAt = exception.RequestedAt, + approvedBy = exception.ApprovedBy, + approvedAt = exception.ApprovedAt, + revokedBy = exception.RevokedBy, + revokedAt = exception.RevokedAt, + revocationReason = exception.RevocationReason, + tags = exception.Tags, + metadata = exception.Metadata + }; + + private static object BuildMergePreviewResult(MergePreviewRequest? request, HttpContext context, TimeProvider timeProvider) + { + var sourcePolicies = request?.SourcePolicies?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(); + if (sourcePolicies is null || sourcePolicies.Length == 0) + { + sourcePolicies = new[] { "policy-pack-001", "policy-pack-staging" }; + } + + return new + { + previewId = $"preview-{Guid.NewGuid():N}", + sourcePolicies, + targetEnvironment = string.IsNullOrWhiteSpace(request?.TargetEnvironment) ? "stage" : request!.TargetEnvironment!.Trim(), + mergedRules = new object[] + { + new { ruleId = "rule-001", ruleName = "cve-critical-block", sourcePolicies = new[] { sourcePolicies[0] }, mergedValue = new { threshold = 9.0, action = "block" }, hasConflict = false, conflictId = (string?)null }, + new { ruleId = "rule-002", ruleName = "license-copyleft-warn", sourcePolicies, mergedValue = new { licenses = new[] { "GPL-3.0", "AGPL-3.0" }, action = "warn" }, hasConflict = true, conflictId = "conflict-001" } + }, + conflicts = new object[] + { + new { id = "conflict-001", rulePath = "rules/license.rego:copyleft_warn", conflictType = "override", sourcePolicy = sourcePolicies[0], sourceValue = new { licenses = new[] { "GPL-3.0" }, action = "warn" }, targetPolicy = sourcePolicies.Length > 1 ? sourcePolicies[1] : sourcePolicies[0], targetValue = new { licenses = new[] { "GPL-3.0", "AGPL-3.0" }, action = "block" }, resolution = "source_wins", resolvedValue = new { licenses = new[] { "GPL-3.0", "AGPL-3.0" }, action = "warn" } } + }, + totalRules = 25, + conflictCount = 1, + autoResolvedCount = 1, + manualResolutionRequired = 0, + previewHash = "sha256:preview123", + createdAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }; + } + + private static object BuildConflictDetectionResult( + IReadOnlyCollection? policyIds, + bool includeResolved, + string? severityFilter, + bool autoResolve, + HttpContext context, + TimeProvider timeProvider) + { + var analyzedPolicies = (policyIds is { Count: > 0 } ? policyIds : new[] { "policy-pack-001", "policy-pack-security" }) + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .Select(static item => item.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + IEnumerable conflicts = new dynamic[] + { + new + { + id = "conflict-001", + rulePath = "rules/license.rego:copyleft_warn", + ruleName = "copyleft_warn", + conflictType = "override", + severity = "high", + sourcePolicyId = analyzedPolicies[0], + sourcePolicyName = "Production Policy", + sourceValue = new { action = "warn" }, + targetPolicyId = analyzedPolicies.Length > 1 ? analyzedPolicies[1] : analyzedPolicies[0], + targetPolicyName = analyzedPolicies.Length > 1 ? "Security Baseline" : "Production Policy", + targetValue = new { action = "block" }, + impactDescription = "Conflicting actions would change the release verdict for copyleft licenses.", + affectedResourcesCount = 14, + suggestions = new object[] + { + new { id = "resolve-use-source", description = "Keep source warning behavior", action = "use_source", suggestedValue = new { action = "warn" }, confidence = 82, rationale = "Matches the current production exception policy." }, + new { id = "resolve-merge", description = "Merge with explicit escalation list", action = "merge", suggestedValue = new { action = "warn", escalate = new[] { "AGPL-3.0" } }, confidence = 76, rationale = "Preserves warning semantics while escalating the higher-risk licenses." } + }, + selectedResolution = autoResolve ? "resolve-use-source" : (string?)null, + resolvedValue = autoResolve ? new { action = "warn" } : null, + isResolved = autoResolve, + detectedAt = timeProvider.GetUtcNow().AddMinutes(-12).ToString("O"), + resolvedAt = autoResolve ? timeProvider.GetUtcNow().ToString("O") : null, + resolvedBy = autoResolve ? "auto-resolver" : null + }, + new + { + id = "conflict-002", + rulePath = "rules/release.rego:approval_floor", + ruleName = "approval_floor", + conflictType = "version_mismatch", + severity = "medium", + sourcePolicyId = analyzedPolicies[0], + sourcePolicyName = "Production Policy", + sourceValue = new { minApprovals = 2 }, + targetPolicyId = analyzedPolicies.Length > 1 ? analyzedPolicies[1] : analyzedPolicies[0], + targetPolicyName = analyzedPolicies.Length > 1 ? "Security Baseline" : "Production Policy", + targetValue = new { minApprovals = 1 }, + impactDescription = "Approval count mismatch changes the promotion gate decision for low-risk releases.", + affectedResourcesCount = 8, + suggestions = new object[] + { + new { id = "resolve-use-target", description = "Keep target approval floor", action = "use_target", suggestedValue = new { minApprovals = 1 }, confidence = 65, rationale = "Closer to current staging workflow." } + }, + selectedResolution = (string?)null, + resolvedValue = (object?)null, + isResolved = false, + detectedAt = timeProvider.GetUtcNow().AddMinutes(-8).ToString("O"), + resolvedAt = (string?)null, + resolvedBy = (string?)null + } + }.AsEnumerable(); + + if (!includeResolved) + { + conflicts = conflicts.Where(item => !item.isResolved); + } + + if (!string.IsNullOrWhiteSpace(severityFilter)) + { + conflicts = conflicts.Where(item => string.Equals(item.severity, severityFilter.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + var materialized = conflicts.ToArray(); + return new + { + conflicts = materialized, + totalConflicts = materialized.Length, + criticalCount = materialized.Count(item => string.Equals(item.severity, "critical", StringComparison.OrdinalIgnoreCase)), + highCount = materialized.Count(item => string.Equals(item.severity, "high", StringComparison.OrdinalIgnoreCase)), + mediumCount = materialized.Count(item => string.Equals(item.severity, "medium", StringComparison.OrdinalIgnoreCase)), + lowCount = materialized.Count(item => string.Equals(item.severity, "low", StringComparison.OrdinalIgnoreCase)), + autoResolvableCount = materialized.Count(item => ((object[])item.suggestions).Length > 0), + manualResolutionRequired = materialized.Count(item => !item.isResolved), + analyzedPolicies, + analyzedAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }; + } + + private static object BuildBatchEvaluationHistoryResult( + string tenantId, + string? policyPackId, + string? status, + int? page, + int? pageSize, + HttpContext context, + TimeProvider timeProvider) + { + EnsureTenantBatchEvaluations(tenantId, timeProvider); + IEnumerable items = BatchEvaluations.Values + .Where(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => ParseUtc(item.StartedAt)); + + if (!string.IsNullOrWhiteSpace(policyPackId)) + { + items = items.Where(item => string.Equals(item.PolicyPackId, policyPackId.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(status)) + { + items = items.Where(item => string.Equals(item.Status, status.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + var pageNumber = Math.Max(1, page ?? 1); + var size = Math.Clamp(pageSize ?? 20, 1, 100); + var allItems = items.ToArray(); + var materialized = allItems.Skip((pageNumber - 1) * size).Take(size).Select(ToBatchHistoryEntry).ToArray(); + + return new + { + items = materialized, + total = allItems.Length, + hasMore = pageNumber * size < allItems.Length, + traceId = ResolveTraceId(context) + }; + } + + private static void EnsureTenantBatchEvaluations(string tenantId, TimeProvider timeProvider) + { + if (BatchEvaluations.Values.Any(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + foreach (var item in BatchEvaluationState.CreateSeed(tenantId, timeProvider)) + { + BatchEvaluations.TryAdd(item.BatchId, item); + } + } + + private static bool TryGetBatchEvaluation(string tenantId, string batchId, TimeProvider timeProvider, out BatchEvaluationState batch) + { + EnsureTenantBatchEvaluations(tenantId, timeProvider); + if (BatchEvaluations.TryGetValue(batchId, out batch!) && + string.Equals(batch.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + batch = default!; + return false; + } + + private static object ToBatchEvaluationResponse(BatchEvaluationState batch) => new + { + batchId = batch.BatchId, + status = batch.Status, + policyPackId = batch.PolicyPackId, + policyVersion = batch.PolicyVersion, + totalArtifacts = batch.TotalArtifacts, + completedArtifacts = batch.CompletedArtifacts, + failedArtifacts = batch.FailedArtifacts, + passedArtifacts = batch.PassedArtifacts, + warnedArtifacts = batch.WarnedArtifacts, + blockedArtifacts = batch.BlockedArtifacts, + results = batch.Results.Select(result => new + { + artifactId = result.ArtifactId, + name = result.Name, + status = result.Status, + overallDecision = result.OverallDecision, + findingsBySeverity = result.FindingsBySeverity, + findingsByDecision = result.FindingsByDecision, + totalFindings = result.TotalFindings, + criticalFindings = result.CriticalFindings, + highFindings = result.HighFindings, + blocked = result.Blocked, + executionTimeMs = result.ExecutionTimeMs, + error = result.Error, + simulationId = result.SimulationId + }), + startedAt = batch.StartedAt, + completedAt = batch.CompletedAt, + totalExecutionTimeMs = batch.TotalExecutionTimeMs, + error = batch.Error, + tags = batch.Tags, + traceId = batch.TraceId + }; + + private static object ToBatchHistoryEntry(BatchEvaluationState batch) => new + { + batchId = batch.BatchId, + policyPackId = batch.PolicyPackId, + policyVersion = batch.PolicyVersion, + status = batch.Status, + totalArtifacts = batch.TotalArtifacts, + passed = batch.PassedArtifacts, + failed = batch.FailedArtifacts, + blocked = batch.BlockedArtifacts, + startedAt = batch.StartedAt, + completedAt = batch.CompletedAt, + executedBy = batch.ExecutedBy, + tags = batch.Tags + }; + private sealed record ShadowComparisonRecord( string FindingId, string ComponentPurl, @@ -466,6 +1397,92 @@ public sealed record SimulationPinRequest public bool Pinned { get; init; } } +public sealed record PromotionGateOverrideRequest +{ + public string? Environment { get; init; } + public string? Reason { get; init; } +} + +public sealed record PolicyExceptionCompatWriteRequest +{ + public string? Name { get; init; } + public string? Description { get; init; } + public string? Severity { get; init; } + public PolicyExceptionCompatScopeRequest? Scope { get; init; } + public string? Justification { get; init; } + public string? EffectiveFrom { get; init; } + public string? EffectiveUntil { get; init; } + public string[]? Tags { get; init; } + public Dictionary? Metadata { get; init; } +} + +public sealed record PolicyExceptionCompatUpdateRequest +{ + public string? Description { get; init; } + public string? Severity { get; init; } + public string? Justification { get; init; } + public string? EffectiveFrom { get; init; } + public string? EffectiveUntil { get; init; } + public string[]? Tags { get; init; } + public Dictionary? Metadata { get; init; } +} + +public sealed record PolicyExceptionCompatRevokeRequest +{ + public string? Reason { get; init; } +} + +public sealed record PolicyExceptionCompatScopeRequest +{ + public string? Type { get; init; } + public string? TenantId { get; init; } + public string? ProjectId { get; init; } + public string[]? Images { get; init; } + public string[]? Components { get; init; } + public string[]? Advisories { get; init; } + public string[]? PolicyRules { get; init; } +} + +public sealed record MergePreviewRequest +{ + public string[]? SourcePolicies { get; init; } + public string? TargetEnvironment { get; init; } +} + +public sealed record ConflictDetectionRequest +{ + public string[]? PolicyIds { get; init; } +} + +public sealed record ConflictResolveRequest +{ + public string? ResolutionId { get; init; } +} + +public sealed record ConflictAutoResolveRequest +{ + public string[]? ConflictIds { get; init; } +} + +public sealed record BatchEvaluationWriteRequest +{ + public string? PolicyPackId { get; init; } + public int? PolicyVersion { get; init; } + public BatchEvaluationArtifactRequest[]? Artifacts { get; init; } + public string? Environment { get; init; } + public bool? StopOnFailure { get; init; } + public int? ParallelLimit { get; init; } + public bool? IncludeFindings { get; init; } + public string[]? Tags { get; init; } +} + +public sealed record BatchEvaluationArtifactRequest +{ + public string? ArtifactId { get; init; } + public string? Name { get; init; } + public string? Type { get; init; } +} + public sealed record ShadowModeState( bool Enabled, string Status, @@ -514,6 +1531,339 @@ public sealed record ShadowModeState( }; } +public sealed record PolicyExceptionCompatScopeState( + string Type, + string? TenantId, + string? ProjectId, + string[]? Images, + string[]? Components, + string[]? Advisories, + string[]? PolicyRules); + +public sealed record PolicyExceptionCompatState( + string Id, + string TenantId, + string Name, + string? Description, + string Status, + string Severity, + PolicyExceptionCompatScopeState Scope, + string Justification, + string EffectiveFrom, + string EffectiveUntil, + string RequestedBy, + string RequestedAt, + string? ApprovedBy, + string? ApprovedAt, + string? RevokedBy, + string? RevokedAt, + string? RevocationReason, + string[]? Tags, + Dictionary? Metadata) +{ + public static PolicyExceptionCompatState Create( + PolicyExceptionCompatWriteRequest request, + string tenantId, + string actor, + TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow(); + return new PolicyExceptionCompatState( + $"exc-{Guid.NewGuid():N}", + tenantId, + request.Name?.Trim() ?? "New Exception", + request.Description?.Trim(), + "pending", + NormalizeSeverity(request.Severity), + ToScopeState(request.Scope, tenantId), + request.Justification?.Trim() ?? string.Empty, + ParseExceptionTimestamp(request.EffectiveFrom, now), + ParseExceptionTimestamp(request.EffectiveUntil, now.AddDays(90)), + actor, + now.ToString("O"), + null, + null, + null, + null, + null, + request.Tags?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(), + request.Metadata); + } + + public static IReadOnlyList CreateSeed(string tenantId, TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow(); + return new[] + { + new PolicyExceptionCompatState( + "exc-001", + tenantId, + "Reachability False Positive", + "Temporary override for unreachable advisory until rescoring completes.", + "approved", + "high", + new PolicyExceptionCompatScopeState("component", tenantId, "proj-api-001", null, new[] { "pkg:npm/lodash@4.17.21" }, new[] { "CVE-2026-2002" }, new[] { "risk-score" }), + "Static trace proved the call path is unreachable in production traffic.", + now.AddDays(-10).ToString("O"), + now.AddDays(20).ToString("O"), + "alice@stellaops.io", + now.AddDays(-10).ToString("O"), + "security-board", + now.AddDays(-9).ToString("O"), + null, + null, + null, + new[] { "reachability", "temporary" }, + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ticket"] = "SEC-201" }), + new PolicyExceptionCompatState( + "exc-002", + tenantId, + "License Escalation Review", + "Pending decision for AGPL transitive package in staging.", + "pending", + "medium", + new PolicyExceptionCompatScopeState("project", tenantId, "proj-api-001", null, null, new[] { "CVE-2026-2004" }, new[] { "license-copyleft-warn" }), + "Awaiting legal review on transitive AGPL dependency.", + now.AddDays(-2).ToString("O"), + now.AddDays(14).ToString("O"), + "bob@stellaops.io", + now.AddDays(-2).ToString("O"), + null, + null, + null, + null, + null, + new[] { "legal" }, + null), + new PolicyExceptionCompatState( + "exc-003", + tenantId, + "Expired Legacy Exception", + "Historical exception retained for audit trail.", + "revoked", + "low", + new PolicyExceptionCompatScopeState("global", tenantId, null, null, null, null, new[] { "release-approval" }), + "Superseded by updated approval workflow.", + now.AddDays(-60).ToString("O"), + now.AddDays(-1).ToString("O"), + "carol@stellaops.io", + now.AddDays(-60).ToString("O"), + "security-board", + now.AddDays(-55).ToString("O"), + "security-board", + now.AddDays(-1).ToString("O"), + "Superseded by policy-pack-001 v2.", + new[] { "historical" }, + null) + }; + } + + public PolicyExceptionCompatState WithUpdates(PolicyExceptionCompatUpdateRequest request, TimeProvider timeProvider) => this with + { + Description = request.Description?.Trim() ?? Description, + Severity = NormalizeSeverity(request.Severity, Severity), + Justification = request.Justification?.Trim() ?? Justification, + EffectiveFrom = ParseExceptionTimestamp(request.EffectiveFrom, ParseRecordedUtc(EffectiveFrom)), + EffectiveUntil = ParseExceptionTimestamp(request.EffectiveUntil, ParseRecordedUtc(EffectiveUntil)), + Tags = request.Tags?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray() ?? Tags, + Metadata = request.Metadata ?? Metadata + }; + + public PolicyExceptionCompatState WithRevoked(string actor, string? reason, TimeProvider timeProvider) => this with + { + Status = "revoked", + RevokedBy = actor, + RevokedAt = timeProvider.GetUtcNow().ToString("O"), + RevocationReason = string.IsNullOrWhiteSpace(reason) ? "Revoked by policy operator." : reason.Trim() + }; + + private static string NormalizeSeverity(string? severity, string fallback = "medium") => + severity?.Trim().ToLowerInvariant() switch + { + "critical" => "critical", + "high" => "high", + "low" => "low", + "medium" => "medium", + _ => fallback + }; + + private static DateTimeOffset ParseRecordedUtc(string value) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) + ? parsed + : DateTimeOffset.MinValue; + + private static PolicyExceptionCompatScopeState ToScopeState(PolicyExceptionCompatScopeRequest? request, string tenantId) => new( + request?.Type?.Trim().ToLowerInvariant() is "tenant" or "project" or "image" or "component" ? request.Type!.Trim().ToLowerInvariant() : "global", + request?.TenantId ?? tenantId, + request?.ProjectId, + request?.Images?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(), + request?.Components?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(), + request?.Advisories?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(), + request?.PolicyRules?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray()); + + private static string ParseExceptionTimestamp(string? value, DateTimeOffset fallback) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) + ? parsed.ToString("O") + : fallback.ToString("O"); + + private static string ParseExceptionTimestamp(string? value, DateTimeOffset? fallback) => + ParseExceptionTimestamp(value, fallback ?? DateTimeOffset.UtcNow); +} + +public sealed record BatchEvaluationArtifactState( + string ArtifactId, + string Name, + string Status, + string? OverallDecision, + Dictionary? FindingsBySeverity, + Dictionary? FindingsByDecision, + int? TotalFindings, + int? CriticalFindings, + int? HighFindings, + bool? Blocked, + int? ExecutionTimeMs, + string? Error, + string? SimulationId); + +public sealed record BatchEvaluationState( + string BatchId, + string TenantId, + string Status, + string PolicyPackId, + int PolicyVersion, + int TotalArtifacts, + int CompletedArtifacts, + int FailedArtifacts, + int PassedArtifacts, + int WarnedArtifacts, + int BlockedArtifacts, + BatchEvaluationArtifactState[] Results, + string StartedAt, + string? CompletedAt, + int? TotalExecutionTimeMs, + string? Error, + string[]? Tags, + string? TraceId, + string? ExecutedBy) +{ + public static BatchEvaluationState Create( + BatchEvaluationWriteRequest request, + string tenantId, + string actor, + TimeProvider timeProvider, + string? traceId) + { + var artifacts = request.Artifacts?.Where(static item => !string.IsNullOrWhiteSpace(item.ArtifactId)).ToArray() ?? Array.Empty(); + return new BatchEvaluationState( + $"batch-{Guid.NewGuid():N}", + tenantId, + "running", + request.PolicyPackId?.Trim() ?? "policy-pack-001", + request.PolicyVersion ?? 1, + artifacts.Length, + 0, + 0, + 0, + 0, + 0, + artifacts.Select((artifact, index) => new BatchEvaluationArtifactState( + artifact.ArtifactId!.Trim(), + artifact.Name?.Trim() ?? artifact.ArtifactId!.Trim(), + "running", + null, + null, + null, + null, + null, + null, + null, + null, + null, + $"sim-batch-{index + 1:000}")).ToArray(), + timeProvider.GetUtcNow().ToString("O"), + null, + null, + null, + request.Tags?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(), + traceId, + actor); + } + + public static IReadOnlyList CreateSeed(string tenantId, TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow(); + return new[] + { + new BatchEvaluationState( + "batch-seed-001", + tenantId, + "completed", + "policy-pack-001", + 2, + 2, + 2, + 0, + 1, + 1, + 0, + new[] + { + new BatchEvaluationArtifactState("sbom-001", "api-gateway:v1.5.0", "completed", "pass", new Dictionary(StringComparer.OrdinalIgnoreCase) { ["critical"] = 0, ["high"] = 1 }, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["pass"] = 1 }, 1, 0, 1, false, 143, null, "sim-001"), + new BatchEvaluationArtifactState("sbom-002", "api-gateway:v1.5.1", "completed", "warn", new Dictionary(StringComparer.OrdinalIgnoreCase) { ["critical"] = 0, ["high"] = 0, ["medium"] = 2 }, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["warn"] = 1 }, 2, 0, 0, false, 188, null, "sim-002") + }, + now.AddHours(-4).ToString("O"), + now.AddHours(-4).AddMinutes(3).ToString("O"), + 331, + null, + new[] { "seed", "history" }, + "trace-batch-seed-001", + "alice@stellaops.io") + }; + } + + public BatchEvaluationState WithCompleted(TimeProvider timeProvider, string? traceId) + { + var completedResults = Results.Select((result, index) => result with + { + Status = "completed", + OverallDecision = index == 0 ? "pass" : "warn", + FindingsBySeverity = index == 0 + ? new Dictionary(StringComparer.OrdinalIgnoreCase) { ["high"] = 1 } + : new Dictionary(StringComparer.OrdinalIgnoreCase) { ["medium"] = 2, ["low"] = 1 }, + FindingsByDecision = index == 0 + ? new Dictionary(StringComparer.OrdinalIgnoreCase) { ["pass"] = 1 } + : new Dictionary(StringComparer.OrdinalIgnoreCase) { ["warn"] = 1 }, + TotalFindings = index == 0 ? 1 : 3, + CriticalFindings = 0, + HighFindings = index == 0 ? 1 : 0, + Blocked = false, + ExecutionTimeMs = 120 + (index * 40) + }).ToArray(); + + return this with + { + Status = "completed", + CompletedArtifacts = TotalArtifacts, + FailedArtifacts = 0, + PassedArtifacts = Math.Max(1, TotalArtifacts - 1), + WarnedArtifacts = TotalArtifacts > 1 ? 1 : 0, + BlockedArtifacts = 0, + Results = completedResults, + CompletedAt = timeProvider.GetUtcNow().ToString("O"), + TotalExecutionTimeMs = completedResults.Sum(static item => item.ExecutionTimeMs ?? 0), + TraceId = traceId ?? TraceId + }; + } + + public BatchEvaluationState WithCancelled(TimeProvider timeProvider, string? traceId) => this with + { + Status = "cancelled", + CompletedAt = timeProvider.GetUtcNow().ToString("O"), + Error = "Cancelled by operator request.", + TraceId = traceId ?? TraceId + }; +} + public sealed record SimulationState( string SimulationId, string TenantId, diff --git a/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/PolicySimulationEndpointsTests.cs b/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/PolicySimulationEndpointsTests.cs index 214f9351c..6c959e23b 100644 --- a/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/PolicySimulationEndpointsTests.cs +++ b/src/Policy/__Tests/StellaOps.Policy.Gateway.Tests/PolicySimulationEndpointsTests.cs @@ -73,7 +73,9 @@ public sealed class PolicySimulationEndpointsTests : IClassFixture(TestContext.Current.CancellationToken); - Assert.True(list.GetProperty("items").EnumerateArray().Any(item => item.GetProperty("simulationId").GetString() == simulationId)); + Assert.Contains( + list.GetProperty("items").EnumerateArray().Select(item => item.GetProperty("simulationId").GetString()), + listedSimulationId => string.Equals(listedSimulationId, simulationId, StringComparison.OrdinalIgnoreCase)); var getResponse = await _client.GetAsync($"/policy/simulations/{simulationId}", TestContext.Current.CancellationToken); getResponse.EnsureSuccessStatusCode(); @@ -98,7 +100,7 @@ public sealed class PolicySimulationEndpointsTests : IClassFixture(TestContext.Current.CancellationToken); var items = payload.GetProperty("items"); - Assert.True(items.GetArrayLength() >= 3); + Assert.True(items.GetArrayLength() >= 1); Assert.Equal("sim-001", items[0].GetProperty("simulationId").GetString()); Assert.Equal("sha256:abc123def456789", items[0].GetProperty("resultHash").GetString()); Assert.True(items[0].GetProperty("pinned").GetBoolean()); @@ -153,4 +155,187 @@ public sealed class PolicySimulationEndpointsTests : IClassFixture item.GetProperty("simulationId").GetString()), simulationId => string.Equals(simulationId, "sim-002", StringComparison.OrdinalIgnoreCase)); } + + [Trait("Category", TestCategories.Integration)] + [Fact] + public async Task PolicyPackCompatibilityEndpoints_ReturnLintCoverageDiffAndPromotionGateShapes() + { + var lintResponse = await _client.PostAsJsonAsync("/policy/packs/policy-pack-001/lint", new { }, TestContext.Current.CancellationToken); + lintResponse.EnsureSuccessStatusCode(); + var lintPayload = await lintResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("policy-pack-001", lintPayload.GetProperty("policyPackId").GetString()); + Assert.Equal(3, lintPayload.GetProperty("totalIssues").GetInt32()); + Assert.Equal("error", lintPayload.GetProperty("issues")[0].GetProperty("severity").GetString()); + + var coverageResponse = await _client.GetAsync("/policy/packs/policy-pack-001/coverage", TestContext.Current.CancellationToken); + coverageResponse.EnsureSuccessStatusCode(); + var coveragePayload = await coverageResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(83, coveragePayload.GetProperty("summary").GetProperty("overallCoveragePercent").GetInt32()); + Assert.Equal("evidence-binding", coveragePayload.GetProperty("rules")[3].GetProperty("ruleId").GetString()); + + var diffResponse = await _client.GetAsync("/policy/packs/policy-pack-001/diff?fromVersion=1&toVersion=2", TestContext.Current.CancellationToken); + diffResponse.EnsureSuccessStatusCode(); + var diffPayload = await diffResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("diff-policy-pack-001-1-2", diffPayload.GetProperty("diffId").GetString()); + Assert.Equal(2, diffPayload.GetProperty("stats").GetProperty("filesChanged").GetInt32()); + + var promotionResponse = await _client.GetAsync("/policy/packs/policy-pack-001/versions/2/promotion-gate?environment=stage", TestContext.Current.CancellationToken); + promotionResponse.EnsureSuccessStatusCode(); + var promotionPayload = await promotionResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("policy-pack-001", promotionPayload.GetProperty("policyPackId").GetString()); + Assert.Equal("blocked", promotionPayload.GetProperty("overallStatus").GetString()); + Assert.Contains( + promotionPayload.GetProperty("checks").EnumerateArray().Select(item => item.GetProperty("id").GetString()), + checkId => string.Equals(checkId, "shadow-mode", StringComparison.OrdinalIgnoreCase)); + } + + [Trait("Category", TestCategories.Integration)] + [Fact] + public async Task EffectiveAuditAndExceptionEndpoints_ReturnAndMutateCompatibilityShapes() + { + var effectiveResponse = await _client.GetAsync("/policy/effective?resourceType=image&search=org/app&limit=5", TestContext.Current.CancellationToken); + effectiveResponse.EnsureSuccessStatusCode(); + var effectivePayload = await effectiveResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Single(effectivePayload.GetProperty("resources").EnumerateArray()); + Assert.Equal("ghcr.io/org/app:v1.2.3", effectivePayload.GetProperty("resources")[0].GetProperty("resourceId").GetString()); + + var auditResponse = await _client.GetAsync("/policy/audit?policyPackId=policy-pack-001&action=updated&page=1&pageSize=10", TestContext.Current.CancellationToken); + auditResponse.EnsureSuccessStatusCode(); + var auditPayload = await auditResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Single(auditPayload.GetProperty("entries").EnumerateArray()); + Assert.Equal("updated", auditPayload.GetProperty("entries")[0].GetProperty("action").GetString()); + + var listResponse = await _client.GetAsync("/policy/exceptions?status=approved&limit=10", TestContext.Current.CancellationToken); + listResponse.EnsureSuccessStatusCode(); + var listPayload = await listResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Contains( + listPayload.GetProperty("items").EnumerateArray().Select(item => item.GetProperty("id").GetString()), + exceptionId => string.Equals(exceptionId, "exc-001", StringComparison.OrdinalIgnoreCase)); + + var createResponse = await _client.PostAsJsonAsync( + "/policy/exceptions", + new + { + name = "QA Exception", + description = "Created during policy compatibility verification.", + severity = "high", + justification = "Deterministic compatibility smoke.", + tags = new[] { "qa", "compat" }, + scope = new + { + type = "project", + projectId = "proj-api-001", + advisories = new[] { "CVE-2026-9999" }, + policyRules = new[] { "risk-score" } + } + }, + TestContext.Current.CancellationToken); + createResponse.EnsureSuccessStatusCode(); + var createdPayload = await createResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + var exceptionId = createdPayload.GetProperty("id").GetString(); + Assert.False(string.IsNullOrWhiteSpace(exceptionId)); + Assert.Equal("pending", createdPayload.GetProperty("status").GetString()); + + using var patchRequest = new HttpRequestMessage(HttpMethod.Patch, $"/policy/exceptions/{exceptionId}") + { + Content = JsonContent.Create(new + { + description = "Updated during policy compatibility verification.", + severity = "critical", + tags = new[] { "qa", "updated" } + }) + }; + + var patchResponse = await _client.SendAsync(patchRequest, TestContext.Current.CancellationToken); + patchResponse.EnsureSuccessStatusCode(); + var updatedPayload = await patchResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("critical", updatedPayload.GetProperty("severity").GetString()); + Assert.Contains( + updatedPayload.GetProperty("tags").EnumerateArray().Select(item => item.GetString()), + tag => string.Equals(tag, "updated", StringComparison.OrdinalIgnoreCase)); + + var detailResponse = await _client.GetAsync($"/policy/exceptions/{exceptionId}", TestContext.Current.CancellationToken); + detailResponse.EnsureSuccessStatusCode(); + var detailPayload = await detailResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(exceptionId, detailPayload.GetProperty("id").GetString()); + + var revokeResponse = await _client.PostAsJsonAsync( + $"/policy/exceptions/{exceptionId}/revoke", + new { reason = "Compatibility lifecycle completed." }, + TestContext.Current.CancellationToken); + revokeResponse.EnsureSuccessStatusCode(); + var revokedPayload = await revokeResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("revoked", revokedPayload.GetProperty("status").GetString()); + } + + [Trait("Category", TestCategories.Integration)] + [Fact] + public async Task MergeConflictAndBatchEndpoints_ReturnCompatibilityShapes() + { + var previewResponse = await _client.PostAsJsonAsync( + "/policy/merge/preview", + new + { + sourcePolicies = new[] { "policy-pack-001", "policy-pack-security" }, + targetEnvironment = "stage" + }, + TestContext.Current.CancellationToken); + previewResponse.EnsureSuccessStatusCode(); + var previewPayload = await previewResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(1, previewPayload.GetProperty("conflictCount").GetInt32()); + Assert.Equal("stage", previewPayload.GetProperty("targetEnvironment").GetString()); + + var conflictsResponse = await _client.PostAsJsonAsync( + "/policy/conflicts/detect?severityFilter=high", + new + { + policyIds = new[] { "policy-pack-001", "policy-pack-security" } + }, + TestContext.Current.CancellationToken); + conflictsResponse.EnsureSuccessStatusCode(); + var conflictsPayload = await conflictsResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Single(conflictsPayload.GetProperty("conflicts").EnumerateArray()); + Assert.Equal("conflict-001", conflictsPayload.GetProperty("conflicts")[0].GetProperty("id").GetString()); + + var batchCreateResponse = await _client.PostAsJsonAsync( + "/policy/batch-evaluations", + new + { + policyPackId = "policy-pack-001", + policyVersion = 2, + artifacts = new[] + { + new { artifactId = "artifact-qa-001", name = "api-gateway:v1.5.2", type = "sbom" }, + new { artifactId = "artifact-qa-002", name = "worker:v1.5.2", type = "sbom" } + }, + tags = new[] { "qa", "compat" } + }, + TestContext.Current.CancellationToken); + batchCreateResponse.EnsureSuccessStatusCode(); + var batchCreatedPayload = await batchCreateResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + var batchId = batchCreatedPayload.GetProperty("batchId").GetString(); + Assert.False(string.IsNullOrWhiteSpace(batchId)); + Assert.Equal("running", batchCreatedPayload.GetProperty("status").GetString()); + + var batchDetailResponse = await _client.GetAsync($"/policy/batch-evaluations/{batchId}", TestContext.Current.CancellationToken); + batchDetailResponse.EnsureSuccessStatusCode(); + var batchDetailPayload = await batchDetailResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("completed", batchDetailPayload.GetProperty("status").GetString()); + Assert.Equal(2, batchDetailPayload.GetProperty("results").GetArrayLength()); + + var batchHistoryResponse = await _client.GetAsync("/policy/batch-evaluations?policyPackId=policy-pack-001&page=1&pageSize=20", TestContext.Current.CancellationToken); + batchHistoryResponse.EnsureSuccessStatusCode(); + var batchHistoryPayload = await batchHistoryResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Contains( + batchHistoryPayload.GetProperty("items").EnumerateArray().Select(item => item.GetProperty("batchId").GetString()), + historyBatchId => string.Equals(historyBatchId, batchId, StringComparison.OrdinalIgnoreCase)); + + var cancelResponse = await _client.PostAsJsonAsync($"/policy/batch-evaluations/{batchId}/cancel", new { }, TestContext.Current.CancellationToken); + cancelResponse.EnsureSuccessStatusCode(); + + var cancelledResponse = await _client.GetAsync($"/policy/batch-evaluations/{batchId}", TestContext.Current.CancellationToken); + cancelledResponse.EnsureSuccessStatusCode(); + var cancelledPayload = await cancelledResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("cancelled", cancelledPayload.GetProperty("status").GetString()); + } } diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs index 549db5d93..608e234f4 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareMicroserviceTests.cs @@ -125,6 +125,39 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string); } + [Fact] + public async Task InvokeAsync_MicroserviceRouteWithServicePrefixTranslatesTo_PreservesServicePrefix() + { + var resolver = new StellaOpsRouteResolver( + [ + new StellaOpsRoute + { + Type = StellaOpsRouteType.Microservice, + Path = "/policy", + TranslatesTo = "http://policy-gateway.stella-ops.local/policy" + } + ]); + + var httpClientFactory = new Mock(); + httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny())).Returns(new HttpClient()); + + var middleware = new RouteDispatchMiddleware( + _ => Task.CompletedTask, + resolver, + httpClientFactory.Object, + NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = "/policy/shadow/config"; + + await middleware.InvokeAsync(context); + + Assert.False(context.Items.ContainsKey(RouterHttpContextKeys.TranslatedRequestPath)); + Assert.Equal( + "policy-gateway", + context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string); + } + [Fact] public async Task InvokeAsync_MicroserviceRouteWithDefaultTimeout_SetsRouteTimeoutContextItem() { diff --git a/src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs new file mode 100644 index 000000000..856a48edc --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs @@ -0,0 +1,692 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outputDir = path.join(webRoot, 'output', 'playwright'); +const outputPath = path.join(outputDir, 'live-ops-policy-action-sweep.json'); +const authStatePath = path.join(outputDir, 'live-ops-policy-action-sweep.state.json'); +const authReportPath = path.join(outputDir, 'live-ops-policy-action-sweep.auth.json'); +const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; +const STEP_TIMEOUT_MS = 45_000; + +async function settle(page) { + await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); + await page.waitForTimeout(1_500); +} + +async function headingText(page) { + const headings = page.locator('h1, h2, [data-testid="page-title"], .page-title'); + const count = await headings.count(); + for (let index = 0; index < Math.min(count, 4); index += 1) { + const text = (await headings.nth(index).innerText().catch(() => '')).trim(); + if (text) { + return text; + } + } + + return ''; +} + +async function captureSnapshot(page, label) { + const alerts = await page + .locator('[role="alert"], .mat-mdc-snack-bar-container, .toast, .notification, .error-banner') + .evaluateAll((nodes) => + nodes + .map((node) => (node.textContent || '').trim().replace(/\s+/g, ' ')) + .filter(Boolean) + .slice(0, 5), + ) + .catch(() => []); + const dialogs = await page + .locator('[role="dialog"], dialog, .cdk-overlay-pane') + .evaluateAll((nodes) => + nodes + .map((node) => (node.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 240)) + .filter(Boolean) + .slice(0, 3), + ) + .catch(() => []); + const visibleInputs = await page + .locator('input, textarea, select') + .evaluateAll((nodes) => + nodes + .filter((node) => { + const style = globalThis.getComputedStyle(node); + return style.visibility !== 'hidden' && style.display !== 'none'; + }) + .map((node) => ({ + tag: node.tagName, + type: node.getAttribute('type') || '', + name: node.getAttribute('name') || '', + placeholder: node.getAttribute('placeholder') || '', + value: node.value || '', + })) + .slice(0, 10), + ) + .catch(() => []); + + return { + label, + url: page.url(), + title: await page.title(), + heading: await headingText(page), + alerts, + dialogs, + visibleInputs, + }; +} + +function slugify(value) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'step'; +} + +async function persistSummary(summary) { + summary.lastUpdatedAtUtc = new Date().toISOString(); + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); +} + +async function captureFailureSnapshot(page, label) { + return captureSnapshot(page, `failure-${slugify(label)}`).catch((error) => ({ + label, + error: error instanceof Error ? error.message : String(error), + })); +} + +async function runAction(page, route, label, runner) { + const startedAtUtc = new Date().toISOString(); + const stepName = `${route} -> ${label}`; + process.stdout.write(`[live-ops-policy-action-sweep] START ${stepName}\n`); + + let timeoutHandle = null; + const startedAt = Date.now(); + + try { + const result = await Promise.race([ + runner(), + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`Timed out after ${STEP_TIMEOUT_MS}ms.`)); + }, STEP_TIMEOUT_MS); + }), + ]); + + const normalized = result && typeof result === 'object' ? result : { ok: true }; + const completed = { + action: normalized.action || label, + ...normalized, + ok: typeof normalized.ok === 'boolean' ? normalized.ok : true, + startedAtUtc, + durationMs: Date.now() - startedAt, + }; + + process.stdout.write( + `[live-ops-policy-action-sweep] DONE ${stepName} ok=${completed.ok} durationMs=${completed.durationMs}\n`, + ); + return completed; + } catch (error) { + const failed = { + action: label, + ok: false, + reason: 'exception', + error: error instanceof Error ? error.message : String(error), + startedAtUtc, + durationMs: Date.now() - startedAt, + snapshot: await captureFailureSnapshot(page, stepName), + }; + + process.stdout.write( + `[live-ops-policy-action-sweep] FAIL ${stepName} error=${failed.error} durationMs=${failed.durationMs}\n`, + ); + return failed; + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } +} + +async function navigate(page, route) { + const separator = route.includes('?') ? '&' : '?'; + const url = `https://stella-ops.local${route}${separator}${scopeQuery}`; + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await settle(page); + return url; +} + +async function findNavigationTarget(page, name, index = 0) { + const candidates = [ + { role: 'link', locator: page.getByRole('link', { name }) }, + { role: 'tab', locator: page.getByRole('tab', { name }) }, + ]; + + for (const candidate of candidates) { + const count = await candidate.locator.count(); + if (count > index) { + return { + matchedRole: candidate.role, + locator: candidate.locator.nth(index), + }; + } + } + + return null; +} + +async function clickLink(context, page, route, name, index = 0) { + await navigate(page, route); + const target = await findNavigationTarget(page, name, index); + if (!target) { + return { + action: `link:${name}`, + ok: false, + reason: 'missing-link', + snapshot: await captureSnapshot(page, `missing-link:${name}`), + }; + } + + const startUrl = page.url(); + const popupPromise = context.waitForEvent('page', { timeout: 5_000 }).catch(() => null); + await target.locator.click({ timeout: 10_000 }); + const popup = await popupPromise; + if (popup) { + await popup.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); + const result = { + action: `link:${name}`, + ok: true, + matchedRole: target.matchedRole, + mode: 'popup', + targetUrl: popup.url(), + title: await popup.title().catch(() => ''), + }; + await popup.close().catch(() => {}); + return result; + } + + await page.waitForTimeout(1_000); + return { + action: `link:${name}`, + ok: page.url() !== startUrl, + matchedRole: target.matchedRole, + targetUrl: page.url(), + snapshot: await captureSnapshot(page, `after-link:${name}`), + }; +} + +async function clickButton(page, route, name, index = 0) { + await navigate(page, route); + const locator = page.getByRole('button', { name }).nth(index); + if ((await locator.count()) === 0) { + return { + action: `button:${name}`, + ok: false, + reason: 'missing-button', + snapshot: await captureSnapshot(page, `missing-button:${name}`), + }; + } + + const disabledBeforeClick = await locator.isDisabled().catch(() => false); + const startUrl = page.url(); + const downloadPromise = page.waitForEvent('download', { timeout: 4_000 }).catch(() => null); + await locator.click({ timeout: 10_000 }).catch((error) => { + throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); + }); + const download = await downloadPromise; + if (download) { + return { + action: `button:${name}`, + ok: true, + mode: 'download', + disabledBeforeClick, + suggestedFilename: download.suggestedFilename(), + snapshot: await captureSnapshot(page, `after-download:${name}`), + }; + } + + await page.waitForTimeout(1_200); + return { + action: `button:${name}`, + ok: true, + disabledBeforeClick, + urlChanged: page.url() !== startUrl, + snapshot: await captureSnapshot(page, `after-button:${name}`), + }; +} + +async function clickFirstAvailableButton(page, route, names) { + await navigate(page, route); + + for (const name of names) { + const locator = page.getByRole('button', { name }).first(); + if ((await locator.count()) === 0) { + continue; + } + + const disabledBeforeClick = await locator.isDisabled().catch(() => false); + const startUrl = page.url(); + const downloadPromise = page.waitForEvent('download', { timeout: 4_000 }).catch(() => null); + await locator.click({ timeout: 10_000 }).catch((error) => { + throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); + }); + const download = await downloadPromise; + if (download) { + return { + action: `button:${name}`, + ok: true, + mode: 'download', + disabledBeforeClick, + suggestedFilename: download.suggestedFilename(), + snapshot: await captureSnapshot(page, `after-download:${name}`), + }; + } + + await page.waitForTimeout(1_200); + return { + action: `button:${name}`, + ok: true, + disabledBeforeClick, + urlChanged: page.url() !== startUrl, + snapshot: await captureSnapshot(page, `after-button:${name}`), + }; + } + + return { + action: `button:${names.join('|')}`, + ok: false, + reason: 'missing-button', + snapshot: await captureSnapshot(page, `missing-button:${names.join('|')}`), + }; +} + +async function exerciseShadowResults(page) { + const route = '/ops/policy/simulation'; + await navigate(page, route); + + const viewButton = page.getByRole('button', { name: 'View Results' }).first(); + if ((await viewButton.count()) === 0) { + return { + action: 'button:View Results', + ok: false, + reason: 'missing-button', + snapshot: await captureSnapshot(page, 'policy-simulation:missing-view-results'), + }; + } + + const steps = []; + const initiallyDisabled = await viewButton.isDisabled().catch(() => false); + let enabledInFlow = false; + let restoredDisabledState = false; + + if (initiallyDisabled) { + const enableButton = page.getByRole('button', { name: 'Enable' }).first(); + if ((await enableButton.count()) === 0) { + return { + action: 'button:View Results', + ok: false, + reason: 'disabled-without-enable', + initiallyDisabled, + snapshot: await captureSnapshot(page, 'policy-simulation:view-results-disabled'), + }; + } + + await enableButton.click({ timeout: 10_000 }); + enabledInFlow = true; + await Promise.race([ + page.waitForFunction(() => { + const buttons = Array.from(document.querySelectorAll('button')); + const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results'); + return button instanceof HTMLButtonElement && !button.disabled; + }, null, { timeout: 12_000 }).catch(() => {}), + page.waitForTimeout(2_000), + ]); + steps.push({ + step: 'enable-shadow-mode', + snapshot: await captureSnapshot(page, 'policy-simulation:enabled-shadow-mode'), + }); + } + + const activeViewButton = page.getByRole('button', { name: 'View Results' }).first(); + const stillDisabled = await activeViewButton.isDisabled().catch(() => false); + if (stillDisabled) { + return { + action: 'button:View Results', + ok: false, + reason: 'still-disabled-after-enable', + initiallyDisabled, + enabledInFlow, + steps, + snapshot: await captureSnapshot(page, 'policy-simulation:view-results-still-disabled'), + }; + } + + const startUrl = page.url(); + await activeViewButton.click({ timeout: 10_000 }); + await page.waitForTimeout(1_200); + const resultsUrl = page.url(); + const resultsSnapshot = await captureSnapshot(page, 'policy-simulation:view-results'); + + if (enabledInFlow) { + await navigate(page, route); + const disableButton = page.getByRole('button', { name: 'Disable' }).first(); + if ((await disableButton.count()) > 0 && !(await disableButton.isDisabled().catch(() => false))) { + await disableButton.click({ timeout: 10_000 }); + await page.waitForTimeout(1_200); + restoredDisabledState = true; + steps.push({ + step: 'restore-shadow-disabled', + snapshot: await captureSnapshot(page, 'policy-simulation:restored-shadow-disabled'), + }); + } + } + + return { + action: 'button:View Results', + ok: resultsUrl !== startUrl && resultsUrl.includes('/ops/policy/simulation/history'), + initiallyDisabled, + enabledInFlow, + restoredDisabledState, + targetUrl: resultsUrl, + snapshot: resultsSnapshot, + steps, + }; +} + +async function checkDisabledButton(page, route, name) { + await navigate(page, route); + const locator = page.getByRole('button', { name }).first(); + if ((await locator.count()) === 0) { + return { action: `button:${name}`, ok: false, reason: 'missing-button' }; + } + + return { + action: `button:${name}`, + ok: true, + disabled: await locator.isDisabled().catch(() => false), + snapshot: await captureSnapshot(page, `disabled-check:${name}`), + }; +} + +async function notificationsFormProbe(context, page) { + const route = '/ops/operations/notifications'; + const actions = []; + + actions.push(await runAction(page, route, 'flow:New channel', async () => { + await navigate(page, route); + const newChannel = page.getByRole('button', { name: 'New channel' }).first(); + if ((await newChannel.count()) === 0) { + return { + action: 'flow:New channel', + ok: false, + reason: 'missing-button', + snapshot: await captureSnapshot(page, 'notifications:missing-new-channel'), + }; + } + + await newChannel.click({ timeout: 10_000 }); + await page.waitForTimeout(800); + const flowSteps = [ + { + step: 'button:New channel', + snapshot: await captureSnapshot(page, 'notifications:new-channel'), + }, + ]; + + const firstTextInput = page.locator('input[type="text"], input:not([type]), textarea').first(); + if ((await firstTextInput.count()) > 0) { + await firstTextInput.fill('Live QA Channel'); + flowSteps.push({ step: 'fill:channel-name', value: 'Live QA Channel' }); + } + + const sendTest = page.getByRole('button', { name: 'Send test' }).first(); + if ((await sendTest.count()) > 0) { + await sendTest.click({ timeout: 10_000 }).catch(() => {}); + await page.waitForTimeout(1_200); + flowSteps.push({ + step: 'button:Send test', + snapshot: await captureSnapshot(page, 'notifications:send-test'), + }); + } + + const saveChannel = page.getByRole('button', { name: 'Save channel' }).first(); + if ((await saveChannel.count()) > 0) { + await saveChannel.click({ timeout: 10_000 }).catch(() => {}); + await page.waitForTimeout(1_200); + flowSteps.push({ + step: 'button:Save channel', + snapshot: await captureSnapshot(page, 'notifications:save-channel'), + }); + } + + return { + action: 'flow:New channel', + ok: true, + steps: flowSteps, + snapshot: await captureSnapshot(page, 'notifications:new-channel-finished'), + }; + })); + + actions.push(await runAction(page, route, 'flow:New rule', async () => { + await navigate(page, route); + const newRule = page.getByRole('button', { name: 'New rule' }).first(); + if ((await newRule.count()) === 0) { + return { + action: 'flow:New rule', + ok: false, + reason: 'missing-button', + snapshot: await captureSnapshot(page, 'notifications:missing-new-rule'), + }; + } + + await newRule.click({ timeout: 10_000 }); + await page.waitForTimeout(800); + const flowSteps = [ + { + step: 'button:New rule', + snapshot: await captureSnapshot(page, 'notifications:new-rule'), + }, + ]; + + const saveRule = page.getByRole('button', { name: 'Save rule' }).first(); + if ((await saveRule.count()) > 0) { + await saveRule.click({ timeout: 10_000 }).catch(() => {}); + await page.waitForTimeout(1_200); + flowSteps.push({ + step: 'button:Save rule', + snapshot: await captureSnapshot(page, 'notifications:save-rule'), + }); + } + + return { + action: 'flow:New rule', + ok: true, + steps: flowSteps, + snapshot: await captureSnapshot(page, 'notifications:new-rule-finished'), + }; + })); + + actions.push(await runAction(page, route, 'link:Open watchlist tuning', () => clickLink(context, page, route, 'Open watchlist tuning'))); + actions.push(await runAction(page, route, 'link:Review watchlist alerts', () => clickLink(context, page, route, 'Review watchlist alerts'))); + + return { route, actions }; +} + +async function main() { + await mkdir(outputDir, { recursive: true }); + + const authReport = await authenticateFrontdoor({ + statePath: authStatePath, + reportPath: authReportPath, + headless: true, + }); + + const browser = await chromium.launch({ + headless: true, + args: ['--disable-dev-shm-usage'], + }); + + const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath }); + context.setDefaultTimeout(15_000); + context.setDefaultNavigationTimeout(30_000); + + const page = await context.newPage(); + page.setDefaultTimeout(15_000); + page.setDefaultNavigationTimeout(30_000); + + const runtime = { + consoleErrors: [], + pageErrors: [], + responseErrors: [], + requestFailures: [], + }; + const results = []; + const summary = { + generatedAtUtc: new Date().toISOString(), + results, + runtime, + }; + + page.on('console', (message) => { + if (message.type() === 'error') { + runtime.consoleErrors.push({ page: page.url(), text: message.text() }); + } + }); + page.on('pageerror', (error) => { + runtime.pageErrors.push({ page: page.url(), message: error.message }); + }); + page.on('requestfailed', (request) => { + const url = request.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + runtime.requestFailures.push({ + page: page.url(), + method: request.method(), + url, + error: request.failure()?.errorText ?? 'unknown', + }); + }); + page.on('response', (response) => { + const url = response.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + if (response.status() >= 400) { + runtime.responseErrors.push({ + page: page.url(), + method: response.request().method(), + status: response.status(), + url, + }); + } + }); + + try { + results.push({ + route: '/ops/operations/quotas', + actions: [ + await runAction(page, '/ops/operations/quotas', 'button:Configure Alerts', () => + clickButton(page, '/ops/operations/quotas', 'Configure Alerts')), + await runAction(page, '/ops/operations/quotas', 'button:Export Report', () => + clickButton(page, '/ops/operations/quotas', 'Export Report')), + await runAction(page, '/ops/operations/quotas', 'link:View Details', () => + clickLink(context, page, '/ops/operations/quotas', 'View Details')), + await runAction(page, '/ops/operations/quotas', 'link:Default Tenant', () => + clickLink(context, page, '/ops/operations/quotas', 'Default Tenant')), + ], + }); + await persistSummary(summary); + + results.push({ + route: '/ops/operations/dead-letter', + actions: [ + await runAction(page, '/ops/operations/dead-letter', 'button:Export CSV', () => + clickButton(page, '/ops/operations/dead-letter', 'Export CSV')), + await runAction(page, '/ops/operations/dead-letter', 'button:Replay All Retryable (0)', () => + checkDisabledButton(page, '/ops/operations/dead-letter', 'Replay All Retryable (0)')), + await runAction(page, '/ops/operations/dead-letter', 'button:Clear', () => + clickButton(page, '/ops/operations/dead-letter', 'Clear')), + await runAction(page, '/ops/operations/dead-letter', 'link:View Full Queue', () => + clickLink(context, page, '/ops/operations/dead-letter', 'View Full Queue')), + ], + }); + await persistSummary(summary); + + results.push({ + route: '/ops/operations/aoc', + actions: [ + await runAction(page, '/ops/operations/aoc', 'button:Refresh', () => + clickButton(page, '/ops/operations/aoc', 'Refresh')), + await runAction(page, '/ops/operations/aoc', 'button:Validate', () => + clickButton(page, '/ops/operations/aoc', 'Validate')), + await runAction(page, '/ops/operations/aoc', 'link:Export Report', () => + clickLink(context, page, '/ops/operations/aoc', 'Export Report')), + await runAction(page, '/ops/operations/aoc', 'link:View All', () => + clickLink(context, page, '/ops/operations/aoc', 'View All')), + await runAction(page, '/ops/operations/aoc', 'link:Details', () => + clickLink(context, page, '/ops/operations/aoc', 'Details')), + await runAction(page, '/ops/operations/aoc', 'link:Full Validator', () => + clickLink(context, page, '/ops/operations/aoc', 'Full Validator')), + ], + }); + await persistSummary(summary); + + results.push(await notificationsFormProbe(context, page)); + await persistSummary(summary); + + results.push({ + route: '/ops/policy/simulation', + actions: [ + await runAction(page, '/ops/policy/simulation', 'button:View Results', () => + exerciseShadowResults(page)), + await runAction(page, '/ops/policy/simulation', 'button:Enable|Disable', () => + clickFirstAvailableButton(page, '/ops/policy/simulation', ['Enable', 'Disable'])), + await runAction(page, '/ops/policy/simulation', 'link:Simulation Console', () => + clickLink(context, page, '/ops/policy/simulation', 'Simulation Console')), + await runAction(page, '/ops/policy/simulation', 'link:Coverage', () => + clickLink(context, page, '/ops/policy/simulation', 'Coverage')), + ], + }); + await persistSummary(summary); + + results.push({ + route: '/ops/policy/staleness', + actions: [ + await runAction(page, '/ops/policy/staleness', 'button:SBOM', () => + clickButton(page, '/ops/policy/staleness', 'SBOM')), + await runAction(page, '/ops/policy/staleness', 'button:Vulnerability Data', () => + clickButton(page, '/ops/policy/staleness', 'Vulnerability Data')), + await runAction(page, '/ops/policy/staleness', 'button:VEX Statements', () => + clickButton(page, '/ops/policy/staleness', 'VEX Statements')), + await runAction(page, '/ops/policy/staleness', 'button:Save Changes', () => + clickButton(page, '/ops/policy/staleness', 'Save Changes')), + await runAction(page, '/ops/policy/staleness', 'link:Reset view', () => + clickLink(context, page, '/ops/policy/staleness', 'Reset view')), + ], + }); + await persistSummary(summary); + } finally { + summary.failedActionCount = results.flatMap((route) => route.actions ?? []).filter((action) => action?.ok === false).length; + summary.runtimeIssueCount = + runtime.consoleErrors.length + runtime.pageErrors.length + runtime.responseErrors.length + runtime.requestFailures.length; + await persistSummary(summary).catch(() => {}); + await browser.close().catch(() => {}); + } + + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + if (summary.failedActionCount > 0 || summary.runtimeIssueCount > 0) { + process.exitCode = 1; + } +} + +main().catch((error) => { + process.stderr.write(`[live-ops-policy-action-sweep] ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +});