Restore policy frontdoor compatibility and live QA
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,9 @@ public sealed class PolicySimulationEndpointsTests : IClassFixture<TestPolicyGat
|
||||
var listResponse = await _client.GetAsync("/policy/simulations?limit=10", TestContext.Current.CancellationToken);
|
||||
listResponse.EnsureSuccessStatusCode();
|
||||
var list = await listResponse.Content.ReadFromJsonAsync<JsonElement>(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<TestPolicyGat
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(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<TestPolicyGat
|
||||
historyPayload.GetProperty("items").EnumerateArray().Select(item => 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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(TestContext.Current.CancellationToken);
|
||||
Assert.Equal("cancelled", cancelledPayload.GetProperty("status").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(factory => factory.CreateClient(It.IsAny<string>())).Returns(new HttpClient());
|
||||
|
||||
var middleware = new RouteDispatchMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
resolver,
|
||||
httpClientFactory.Object,
|
||||
NullLogger<RouteDispatchMiddleware>.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()
|
||||
{
|
||||
|
||||
692
src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs
Normal file
692
src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs
Normal file
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user