From ac544c00643ca073d363fc53f7e3ce2ce4c2f060 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 10 Mar 2026 00:25:34 +0200 Subject: [PATCH] Repair live watchlist frontdoor routing --- devops/compose/router-gateway-local.json | 2 +- .../router-gateway-local.reverseproxy.json | 2 +- ...Router_watchlist_frontdoor_scope_repair.md | 78 ++++++++ docs/modules/attestor/architecture.md | 12 +- .../attestor/guides/identity-watchlist.md | 6 +- .../AttestorTestWebApplicationFactory.cs | 50 ++++- .../WatchlistEndpointAuthorizationTests.cs | 91 +++++++++ .../AttestorWebServiceComposition.cs | 28 ++- .../StellaOps.Attestor.WebService/TASKS.md | 1 + .../WatchlistEndpoints.cs | 49 ++++- .../live-notifications-watchlist-recheck.mjs | 177 ++++++++++++++++++ 11 files changed, 474 insertions(+), 22 deletions(-) create mode 100644 docs/implplan/SPRINT_20260309_017_Router_watchlist_frontdoor_scope_repair.md create mode 100644 src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/Watchlist/WatchlistEndpointAuthorizationTests.cs create mode 100644 src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index fa2b0cbe3..3d13e1fe8 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -264,7 +264,7 @@ { "Type": "Microservice", "Path": "/api/v1/watchlist", - "TranslatesTo": "http://scanner.stella-ops.local/api/v1/watchlist", + "TranslatesTo": "http://attestor.stella-ops.local/api/v1/watchlist", "PreserveAuthHeaders": true }, { diff --git a/devops/compose/router-gateway-local.reverseproxy.json b/devops/compose/router-gateway-local.reverseproxy.json index 00421437a..f8b09017c 100644 --- a/devops/compose/router-gateway-local.reverseproxy.json +++ b/devops/compose/router-gateway-local.reverseproxy.json @@ -211,7 +211,7 @@ { "Type": "ReverseProxy", "Path": "/api/v1/watchlist", - "TranslatesTo": "http://scanner.stella-ops.local/api/v1/watchlist", + "TranslatesTo": "http://attestor.stella-ops.local/api/v1/watchlist", "PreserveAuthHeaders": true }, { diff --git a/docs/implplan/SPRINT_20260309_017_Router_watchlist_frontdoor_scope_repair.md b/docs/implplan/SPRINT_20260309_017_Router_watchlist_frontdoor_scope_repair.md new file mode 100644 index 000000000..95139755b --- /dev/null +++ b/docs/implplan/SPRINT_20260309_017_Router_watchlist_frontdoor_scope_repair.md @@ -0,0 +1,78 @@ +# Sprint 20260309-017 - Router Watchlist Frontdoor Scope Repair + +## Topic & Scope +- Repair the live watchlist contract exposed from Notifications so the frontdoor routes to the correct service and the backend authorizes the scopes the UI actually receives. +- Keep the fix at the source layers: router frontdoor mapping, Attestor watchlist authorization, and the watchlist documentation that defines the operator-facing contract. +- Verify with targeted Attestor tests plus authenticated live Playwright actions against `https://stella-ops.local` after rebuilding only the touched services. +- Working directory: `devops/compose`. +- Allowed coordination edits: `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/**`, `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/**`, `docs/modules/attestor/**`, `src/Web/StellaOps.Web/scripts/live-ops-policy-action-sweep.mjs`, `docs/implplan/SPRINT_20260309_017_Router_watchlist_frontdoor_scope_repair.md`. +- Expected evidence: targeted `.csproj` test run, rebuilt router/attestor images, redeployed live stack slice, refreshed Playwright artifacts for Notifications -> Watchlist actions. + +## Dependencies & Concurrency +- Depends on `SPRINT_20260309_001_Platform_scratch_setup_bootstrap_restore.md` for the scratch rebuild baseline and `SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md` for the authenticated failure inventory. +- Safe parallelism: do not absorb unrelated Policy, Search, or component-revival changes; stage only the router/Attestor/docs slice for this iteration. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/code-of-conduct/CODE_OF_CONDUCT.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/attestor/architecture.md` +- `docs/modules/attestor/guides/identity-watchlist.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### WATCHLIST-LIVE-017-001 - Repair frontdoor watchlist routing and auth alignment +Status: DONE +Dependency: none +Owners: Developer, Test Automation +Task description: +- Correct the live `/api/v1/watchlist*` frontdoor route so requests reach Attestor instead of Scanner, then align Attestor watchlist authorization and admin checks with the canonical trust scope family already issued to the console session. +- Add targeted tests that prove trust-scoped users can read and mutate tenant watchlist data and that admin-only behaviors still require the elevated admin scope. + +Completion criteria: +- [ ] `/api/v1/watchlist` and `/api/v1/watchlist/alerts` route to Attestor through the frontdoor. +- [ ] Watchlist read/write/admin behavior accepts the canonical trust scopes used by the live UI session. +- [ ] Targeted Attestor tests fail before the change and pass after it. + +### WATCHLIST-LIVE-017-002 - Rebuild and redeploy the repaired router/attestor slice +Status: DONE +Dependency: WATCHLIST-LIVE-017-001 +Owners: Developer, QA +Task description: +- Rebuild the changed images from source, redeploy only the touched runtime slice, and confirm the direct and frontdoor watchlist endpoints are ready before browser verification resumes. + +Completion criteria: +- [ ] Router and Attestor images are rebuilt from the current source. +- [ ] The compose stack is updated without disturbing unrelated in-flight work. +- [ ] Direct and frontdoor probes reach the watchlist surface successfully. + +### WATCHLIST-LIVE-017-003 - Reverify Notifications watchlist actions with Playwright +Status: DONE +Dependency: WATCHLIST-LIVE-017-002 +Owners: QA +Task description: +- Rerun the authenticated Notifications/Watchlist action checks with Playwright, confirm the watchlist pages and actions no longer fail, and capture any remaining defects for the next iteration. + +Completion criteria: +- [ ] Authenticated Playwright rechecks cover the Notifications -> Watchlist tuning and alerts actions on the rebuilt stack. +- [ ] Artifacts are refreshed under `src/Web/StellaOps.Web/output/playwright/`. +- [ ] Remaining failures, if any, are written into the execution log instead of being masked. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-09 | Sprint created after the rebuilt live Notifications sweep proved `/api/v1/watchlist*` was frontdoored to Scanner and the Attestor watchlist auth still enforced stale legacy scope names not present in the live console session. | Developer | +| 2026-03-09 | Repointed `/api/v1/watchlist*` to Attestor in both router configs, aligned watchlist auth/admin checks with `trust:*` plus legacy aliases, and switched watchlist tenant resolution onto the canonical tenant resolver. | Developer | +| 2026-03-09 | Added targeted Attestor watchlist authorization tests and updated watchlist docs to advertise the trust-scope contract that the live console session actually uses. | Developer | +| 2026-03-09 | Rebuilt `router-gateway` and `attestor`, redeployed only those services, verified direct Attestor readiness (`/health/ready`) and frontdoor watchlist API `200`, then reran Playwright to confirm Notifications links now open watchlist tuning and alerts with zero runtime errors. | QA | + +## Decisions & Risks +- Decision: align watchlist authorization with the canonical trust scope family (`trust:read`, `trust:write`, `trust:admin`) instead of preserving the stale dotted `watchlist.*` scopes that the Authority session no longer issues. +- Decision: keep legacy `watchlist:*` and `watchlist.*` aliases accepted in Attestor while moving the documented/live contract to `trust:*`, so older automation stays compatible during the transition. +- Risk: the reverse-proxy and default router configs must stay in sync; update both or the fallback transport mode will drift again. + +## Next Checkpoints +- 2026-03-09: land the router/Attestor/test/doc fix slice. +- 2026-03-09: rebuild and redeploy router plus Attestor from source. +- 2026-03-09: rerun Playwright Notifications watchlist actions and commit the iteration. diff --git a/docs/modules/attestor/architecture.md b/docs/modules/attestor/architecture.md index 799dc4ab0..f843cfa7e 100644 --- a/docs/modules/attestor/architecture.md +++ b/docs/modules/attestor/architecture.md @@ -927,11 +927,13 @@ The Attestor provides proactive monitoring for signing identities appearing in t ### Scope Hierarchy -| Scope | Visibility | Who Can Create | -|-------|------------|----------------| -| `tenant` | Owning tenant only | Tenant admins | -| `global` | All tenants | Platform admins | -| `system` | All tenants (read-only) | System bootstrap | +| Scope | Visibility | Who Can Create | +|-------|------------|----------------| +| `tenant` | Owning tenant only | Operators with `trust:write` | +| `global` | All tenants | Platform admins with `trust:admin` | +| `system` | All tenants (read-only) | System bootstrap | + +Authorization for the live watchlist surface follows the canonical trust scope family (`trust:read`, `trust:write`, `trust:admin`). The service still accepts legacy `watchlist:*` aliases for backward compatibility, but new clients and UI sessions should rely on the trust scopes. ### Event Flow diff --git a/docs/modules/attestor/guides/identity-watchlist.md b/docs/modules/attestor/guides/identity-watchlist.md index de3d7115b..66b2cad60 100644 --- a/docs/modules/attestor/guides/identity-watchlist.md +++ b/docs/modules/attestor/guides/identity-watchlist.md @@ -52,10 +52,12 @@ A watchlist entry defines an identity pattern to monitor and alert configuration | Scope | Description | Who Can Create | |-------|-------------|----------------| -| `tenant` | Visible only to owning tenant | Any user with `watchlist:write` | -| `global` | Shared across all tenants | Administrators only | +| `tenant` | Visible only to owning tenant | Any user with `trust:write` | +| `global` | Shared across all tenants | Administrators with `trust:admin` | | `system` | System-managed entries | System only | +Console and frontdoor watchlist flows use the canonical trust scope family: `trust:read`, `trust:write`, and `trust:admin`. Legacy `watchlist:*` aliases remain accepted for older clients, but new integrations should use the trust scopes. + ## CLI Usage ### Adding a Watchlist Entry diff --git a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/Fixtures/AttestorTestWebApplicationFactory.cs b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/Fixtures/AttestorTestWebApplicationFactory.cs index b88ae8b5e..d4e4d66e3 100644 --- a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/Fixtures/AttestorTestWebApplicationFactory.cs +++ b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/Fixtures/AttestorTestWebApplicationFactory.cs @@ -15,6 +15,8 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StackExchange.Redis; +using StellaOps.Auth.Abstractions; +using StellaOps.Auth.ServerIntegration; using StellaOps.Attestor.Core.Bulk; using StellaOps.Attestor.Core.Offline; using StellaOps.Attestor.Core.Storage; @@ -99,6 +101,10 @@ public class AttestorTestWebApplicationFactory : WebApplicationFactory authenticationScheme: TestAuthHandler.SchemeName, displayName: null, configureOptions: options => { options.TimeProvider ??= TimeProvider.System; }); + authBuilder.AddScheme( + authenticationScheme: StellaOpsAuthenticationDefaults.AuthenticationScheme, + displayName: null, + configureOptions: options => { options.TimeProvider ??= TimeProvider.System; }); services.TryAddSingleton(TimeProvider.System); }); } @@ -121,14 +127,48 @@ public class TestAuthHandler : AuthenticationHandler HandleAuthenticateAsync() { - var claims = new[] + if (!Request.Headers.TryGetValue("Authorization", out var authorizationHeader) || + string.IsNullOrWhiteSpace(authorizationHeader.ToString())) { - new Claim(ClaimTypes.Name, "test-user"), - new Claim(ClaimTypes.NameIdentifier, "test-user-id"), - new Claim("tenant_id", "test-tenant"), - new Claim("scope", "attestor:read attestor:write attestor.read attestor.write attestor.verify") + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var tenantId = Request.Headers.TryGetValue("X-Test-Tenant", out var tenantHeader) && + !string.IsNullOrWhiteSpace(tenantHeader.ToString()) + ? tenantHeader.ToString() + : "test-tenant"; + var subject = Request.Headers.TryGetValue("X-Test-Subject", out var subjectHeader) && + !string.IsNullOrWhiteSpace(subjectHeader.ToString()) + ? subjectHeader.ToString() + : "test-user-id"; + var name = Request.Headers.TryGetValue("X-Test-Name", out var nameHeader) && + !string.IsNullOrWhiteSpace(nameHeader.ToString()) + ? nameHeader.ToString() + : "test-user"; + var scopeValue = Request.Headers.TryGetValue("X-Test-Scopes", out var scopesHeader) && + !string.IsNullOrWhiteSpace(scopesHeader.ToString()) + ? scopesHeader.ToString() + : "attestor:read attestor:write attestor.read attestor.write attestor.verify trust:read trust:write trust:admin"; + + var claims = new List + { + new(ClaimTypes.Name, name), + new(ClaimTypes.NameIdentifier, subject), + new(StellaOpsClaimTypes.Subject, subject), + new(StellaOpsClaimTypes.Tenant, tenantId), + new("tenant_id", tenantId), + new("scope", scopeValue) }; + if (Request.Headers.TryGetValue("X-Test-Roles", out var rolesHeader) && + !string.IsNullOrWhiteSpace(rolesHeader.ToString())) + { + foreach (var role in rolesHeader.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + } + var identity = new ClaimsIdentity(claims, SchemeName); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, SchemeName); diff --git a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/Watchlist/WatchlistEndpointAuthorizationTests.cs b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/Watchlist/WatchlistEndpointAuthorizationTests.cs new file mode 100644 index 000000000..532c09322 --- /dev/null +++ b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/Watchlist/WatchlistEndpointAuthorizationTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using FluentAssertions; +using StellaOps.Attestor.Tests.Fixtures; +using StellaOps.Attestor.Watchlist.Models; +using StellaOps.Attestor.WebService; +using Xunit; + +namespace StellaOps.Attestor.Tests.Watchlist; + +public sealed class WatchlistEndpointAuthorizationTests : IClassFixture +{ + private readonly AttestorTestWebApplicationFactory _factory; + + public WatchlistEndpointAuthorizationTests(AttestorTestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task ListWatchlistEntries_WithTrustReadScope_ReturnsOk() + { + using var client = CreateClient("trust:read", tenantId: "read-tenant"); + + var response = await client.GetAsync("/api/v1/watchlist?includeGlobal=false"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.TotalCount.Should().Be(0); + } + + [Fact] + public async Task CreateWatchlistEntry_WithTrustWriteScope_UsesResolvedTenant() + { + using var client = CreateClient("trust:write", tenantId: "Demo-Prod"); + + var response = await client.PostAsJsonAsync("/api/v1/watchlist", new WatchlistEntryRequest + { + DisplayName = "GitHub Actions", + Issuer = "https://token.actions.githubusercontent.com", + Scope = WatchlistScope.Tenant + }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.TenantId.Should().Be("demo-prod"); + payload.CreatedBy.Should().Be("test-user-id"); + } + + [Fact] + public async Task CreateGlobalWatchlistEntry_WithTrustWriteScope_ReturnsForbidden() + { + using var client = CreateClient("trust:write"); + + var response = await client.PostAsJsonAsync("/api/v1/watchlist", new WatchlistEntryRequest + { + DisplayName = "Global watch", + Issuer = "https://issuer.example", + Scope = WatchlistScope.Global + }); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task CreateGlobalWatchlistEntry_WithTrustAdminScope_ReturnsCreated() + { + using var client = CreateClient("trust:admin"); + + var response = await client.PostAsJsonAsync("/api/v1/watchlist", new WatchlistEntryRequest + { + DisplayName = "Global watch", + Issuer = "https://issuer.example", + Scope = WatchlistScope.Global + }); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + private HttpClient CreateClient(string scopes, string tenantId = "test-tenant") + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token"); + client.DefaultRequestHeaders.Add("X-Test-Scopes", scopes); + client.DefaultRequestHeaders.Add("X-Test-Tenant", tenantId); + return client; + } +} diff --git a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/AttestorWebServiceComposition.cs b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/AttestorWebServiceComposition.cs index 87dee32da..d2ac3c93c 100644 --- a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/AttestorWebServiceComposition.cs +++ b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/AttestorWebServiceComposition.cs @@ -24,6 +24,7 @@ using StellaOps.Attestor.Persistence; using StellaOps.Attestor.ProofChain; using StellaOps.Attestor.Spdx3; using StellaOps.Attestor.Watchlist; +using StellaOps.Auth.Abstractions; using StellaOps.Attestor.WebService.Endpoints; using StellaOps.Attestor.WebService.Options; using StellaOps.Auth.ServerIntegration; @@ -297,19 +298,40 @@ internal static class AttestorWebServiceComposition options.AddPolicy("watchlist:read", policy => { policy.RequireAuthenticatedUser(); - policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.read", "watchlist.write", "attestor.write")); + policy.RequireAssertion(context => HasAnyScope( + context.User, + StellaOpsScopes.TrustRead, + StellaOpsScopes.TrustWrite, + StellaOpsScopes.TrustAdmin, + "watchlist:read", + "watchlist:write", + "watchlist:admin", + "watchlist.read", + "watchlist.write", + "watchlist.admin")); }); options.AddPolicy("watchlist:write", policy => { policy.RequireAuthenticatedUser(); - policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.write", "attestor.write")); + policy.RequireAssertion(context => HasAnyScope( + context.User, + StellaOpsScopes.TrustWrite, + StellaOpsScopes.TrustAdmin, + "watchlist:write", + "watchlist:admin", + "watchlist.write", + "watchlist.admin")); }); options.AddPolicy("watchlist:admin", policy => { policy.RequireAuthenticatedUser(); - policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.admin", "attestor.write")); + policy.RequireAssertion(context => HasAnyScope( + context.User, + StellaOpsScopes.TrustAdmin, + "watchlist:admin", + "watchlist.admin")); }); }); } diff --git a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/TASKS.md b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/TASKS.md index 194d0d3c8..2241c07fd 100644 --- a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/TASKS.md +++ b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/TASKS.md @@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | Task ID | Status | Notes | | --- | --- | --- | +| WATCHLIST-LIVE-017 | DONE | Sprint 20260309-017: repaired frontdoor watchlist routing and aligned watchlist auth with canonical trust scopes. | | ATTESTOR-225-002 | DOING | Sprint 225: enforce roster-based trust verification before verdict append. | | ATTESTOR-225-003 | DOING | Sprint 225: resolve tenant from authenticated claims and block spoofing. | | ATTESTOR-225-004 | DOING | Sprint 225: implement verdict-by-hash retrieval and tenant-scoped access checks. | diff --git a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/WatchlistEndpoints.cs b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/WatchlistEndpoints.cs index b84414a52..724bd66d4 100644 --- a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/WatchlistEndpoints.cs +++ b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/WatchlistEndpoints.cs @@ -7,9 +7,12 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.Abstractions; +using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Attestor.Watchlist.Matching; using StellaOps.Attestor.Watchlist.Models; using StellaOps.Attestor.Watchlist.Storage; +using System.Security.Claims; using static StellaOps.Localization.T; namespace StellaOps.Attestor.WebService; @@ -286,20 +289,56 @@ internal static class WatchlistEndpoints private static string GetTenantId(HttpContext context) { - return context.User.FindFirst("tenant_id")?.Value ?? "default"; + return StellaOpsTenantResolver.ResolveTenantIdOrDefault(context); } private static string GetUserId(HttpContext context) { - return context.User.FindFirst("sub")?.Value ?? - context.User.FindFirst("name")?.Value ?? - "anonymous"; + return StellaOpsTenantResolver.ResolveActor(context); } private static bool IsAdmin(HttpContext context) { return context.User.IsInRole("admin") || - context.User.HasClaim("scope", "watchlist:admin"); + HasAnyScope(context.User, StellaOpsScopes.TrustAdmin, "watchlist:admin", "watchlist.admin"); + } + + private static bool HasAnyScope(ClaimsPrincipal user, params string[] scopes) + { + if (user.Identity is not { IsAuthenticated: true } || scopes.Length == 0) + { + return false; + } + + foreach (var claim in user.FindAll("scope")) + { + foreach (var granted in claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + foreach (var required in scopes) + { + if (string.Equals(granted, required, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + + foreach (var claim in user.FindAll("scp")) + { + foreach (var granted in claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + foreach (var required in scopes) + { + if (string.Equals(granted, required, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + + return false; } private static bool CanAccessEntry(WatchedIdentity entry, string tenantId) diff --git a/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs b/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs new file mode 100644 index 000000000..2cfb9cb83 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-notifications-watchlist-recheck.mjs @@ -0,0 +1,177 @@ +#!/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 __dirname = path.dirname(fileURLToPath(import.meta.url)); +const webRoot = path.resolve(__dirname, '..'); +const outputDir = path.join(webRoot, 'output', 'playwright'); +const outputPath = path.join(outputDir, 'live-notifications-watchlist-recheck.json'); +const authStatePath = path.join(outputDir, 'live-notifications-watchlist-recheck.state.json'); +const authReportPath = path.join(outputDir, 'live-notifications-watchlist-recheck.auth.json'); +const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; + +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(() => []); + + return { + label, + url: page.url(), + title: await page.title(), + heading: await headingText(page), + alerts, + }; +} + +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 clickLinkAndVerify(page, route, linkName, expectedPath) { + await navigate(page, route); + const locator = page.getByRole('link', { name: linkName }).first(); + if ((await locator.count()) === 0) { + return { + action: `link:${linkName}`, + ok: false, + reason: 'missing-link', + snapshot: await captureSnapshot(page, `missing-link:${linkName}`), + }; + } + + await locator.click({ timeout: 10_000 }); + await page.waitForURL((url) => url.pathname.includes(expectedPath), { timeout: 15_000 }); + await settle(page); + + return { + action: `link:${linkName}`, + ok: page.url().includes(expectedPath), + snapshot: await captureSnapshot(page, `after-link:${linkName}`), + }; +} + +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 }); + const page = await context.newPage(); + const runtime = { + consoleErrors: [], + pageErrors: [], + responseErrors: [], + requestFailures: [], + }; + + 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, + }); + } + }); + + const results = []; + results.push(await clickLinkAndVerify( + page, + '/ops/operations/notifications', + 'Open watchlist tuning', + '/setup/trust-signing/watchlist/tuning', + )); + results.push(await clickLinkAndVerify( + page, + '/ops/operations/notifications', + 'Review watchlist alerts', + '/setup/trust-signing/watchlist/alerts', + )); + + const summary = { + generatedAtUtc: new Date().toISOString(), + results, + runtime, + }; + + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + + await browser.close(); +} + +main().catch((error) => { + process.stderr.write(`[live-notifications-watchlist-recheck] ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +});