Restore policy frontdoor compatibility and live QA
This commit is contained in:
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