Repair live watchlist frontdoor routing
This commit is contained in:
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Program>
|
||||
authenticationScheme: TestAuthHandler.SchemeName,
|
||||
displayName: null,
|
||||
configureOptions: options => { options.TimeProvider ??= TimeProvider.System; });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
|
||||
authenticationScheme: StellaOpsAuthenticationDefaults.AuthenticationScheme,
|
||||
displayName: null,
|
||||
configureOptions: options => { options.TimeProvider ??= TimeProvider.System; });
|
||||
services.TryAddSingleton<TimeProvider>(TimeProvider.System);
|
||||
});
|
||||
}
|
||||
@@ -121,14 +127,48 @@ public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions
|
||||
|
||||
protected override Task<AuthenticateResult> 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<Claim>
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -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<AttestorTestWebApplicationFactory>
|
||||
{
|
||||
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<WatchlistListResponse>();
|
||||
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<WatchlistEntryResponse>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user