feat(ui): ship trust-owned identity watchlist shell

This commit is contained in:
master
2026-03-07 18:48:35 +02:00
parent 6aa8bb5095
commit 9d3bed1d0e
25 changed files with 2810 additions and 1142 deletions

View File

@@ -30,7 +30,7 @@
## Delivery Tracker
### FE-WL-001 - Wire the canonical watchlist shell into Setup
Status: TODO
Status: DONE
Dependency: none
Owners: Product Manager, FE Architect
Task description:
@@ -38,12 +38,12 @@ Task description:
- Make the shell routable and usable with working tab navigation and scope-aware header behavior.
Completion criteria:
- [ ] Watchlist is reachable from the active shell navigation.
- [ ] Canonical routes and tab behavior are wired in code.
- [ ] Scope-aware header behavior works from the mounted shell.
- [x] Watchlist is reachable from the active shell navigation.
- [x] Canonical routes and tab behavior are wired in code.
- [x] Scope-aware header behavior works from the mounted shell.
### FE-WL-002 - Ship the Entries workflow
Status: TODO
Status: DONE
Dependency: FE-WL-001
Owners: Developer, FE Architect
Task description:
@@ -51,12 +51,12 @@ Task description:
- Ensure operators can create, edit, duplicate, enable/disable, delete, and test matching rules from the mounted shell.
Completion criteria:
- [ ] Entry CRUD flows work from the mounted shell.
- [ ] Edit/create uses a contextual panel or drawer instead of a detached page.
- [ ] Pattern test is wired and usable within the entry-editing flow.
- [x] Entry CRUD flows work from the mounted shell.
- [x] Edit/create uses a contextual panel or drawer instead of a detached page.
- [x] Pattern test is wired and usable within the entry-editing flow.
### FE-WL-003 - Ship the Alerts workflow
Status: TODO
Status: DONE
Dependency: FE-WL-001
Owners: Developer, Product Manager
Task description:
@@ -64,12 +64,12 @@ Task description:
- Make alert-detail deep links work from Mission Control and back into the owning watchlist rule.
Completion criteria:
- [ ] Alert listing and filtering work in the mounted shell.
- [ ] Alert-detail drawer shows the required context and actions.
- [ ] Operators can jump between alert detail and the owning watchlist entry.
- [x] Alert listing and filtering work in the mounted shell.
- [x] Alert-detail drawer shows the required context and actions.
- [x] Operators can jump between alert detail and the owning watchlist entry.
### FE-WL-004 - Ship tuning and diagnostics
Status: TODO
Status: DONE
Dependency: FE-WL-001
Owners: Developer, Documentation author
Task description:
@@ -77,12 +77,12 @@ Task description:
- Align the shipped tab with the operational runbook so the page is usable for real operator tuning.
Completion criteria:
- [ ] Dedup and channel controls are wired into the page.
- [ ] Operational KPI cards render in the mounted shell.
- [ ] Tuning guidance matches the operational runbook terminology.
- [x] Dedup and channel controls are wired into the page.
- [x] Operational KPI cards render in the mounted shell.
- [x] Tuning guidance matches the operational runbook terminology.
### FE-WL-005 - Wire Mission Control and Notifications entry points
Status: TODO
Status: DONE
Dependency: FE-WL-003
Owners: FE Architect, Developer
Task description:
@@ -90,12 +90,12 @@ Task description:
- Ensure those surfaces expose outcomes only and send operators into the canonical watchlist shell for action.
Completion criteria:
- [ ] Mission Control links open the working watchlist alerts flow.
- [ ] Notifications links open tuning or alert views in the canonical shell.
- [ ] `returnTo` behavior preserves operator context across shells.
- [x] Mission Control links open the working watchlist alerts flow.
- [x] Notifications links open tuning or alert views in the canonical shell.
- [x] `returnTo` behavior preserves operator context across shells.
### FE-WL-006 - Verify, document, and cut over the feature
Status: TODO
Status: DONE
Dependency: FE-WL-002
Owners: QA, Documentation author
Task description:
@@ -103,24 +103,33 @@ Task description:
- Update docs and cutover notes so Watchlist is treated as a shipped feature, not an orphan page.
Completion criteria:
- [ ] Playwright scenarios cover entries, alerts, and tuning.
- [ ] Scope-sensitive behaviors are explicitly verified.
- [ ] Docs and rollout notes reflect the mounted and usable feature.
- [x] Playwright scenarios cover entries, alerts, and tuning.
- [x] Scope-sensitive behaviors are explicitly verified.
- [x] Docs and rollout notes reflect the mounted and usable feature.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-07 | Sprint created to ship Watchlist as a Trust & Signing-owned shell with working entries, alerts, tuning, and secondary surfacing in Mission Control and Notifications. | Project Manager |
| 2026-03-07 | Implementation started. Freezing Watchlist under the Trust Admin shell with route-backed tabs, query-param deep links, split detail panels, and cross-shell entry points from Mission Control and Notifications. | Developer |
| 2026-03-07 | Implemented the canonical Watchlist shell under `Setup > Trust & Signing`, wired Mission Control and Notifications deep links, and updated the shipped UX docs. | Developer |
| 2026-03-07 | Verified the feature with targeted Angular tests (`npx ng test --watch=false --include src/tests/watchlist/identity-watchlist-management-ui.component.spec.ts --include src/tests/trust_admin/trust-scoring-dashboard-ui.behavior.spec.ts --include src/tests/notify/notify-watchlist-handoff.spec.ts`) and Playwright browser scenarios (`npx playwright test tests/e2e/watchlist-shell.spec.ts --workers=1`). | QA |
## Decisions & Risks
- Decision: Watchlist belongs under `Setup > Trust & Signing`, with alert visibility surfaced elsewhere.
- Decision: configuration and alert history remain in one shell; they should not be split into separate products.
- Decision: the canonical mounted routes are `/setup/trust-signing/watchlist/{entries|alerts|tuning}` and all secondary entry points now deep-link into that route family.
- Risk: Mission Control may try to absorb watchlist because it already owns alerts.
- Mitigation: freeze the ownership boundary and only allow alert-source chips and deep links from Mission Control.
- Risk: scope handling across tenant, global, and system rules can create hidden permissions complexity.
- Mitigation: require scope-aware header behavior and QA coverage before rollout.
- Delivery rule: this sprint is only complete when Watchlist is visible in navigation, usable end to end, and its key alert and tuning workflows are verified.
- Reference design note: `docs/modules/ui/watchlist-operations/README.md`.
- Docs synced:
- `docs/modules/ui/watchlist-operations/README.md`
- `docs/features/checked/web/identity-watchlist-management-ui.md`
- `docs/modules/ui/TASKS.md`
- `docs/modules/ui/implementation_plan.md`
## Next Checkpoints
- 2026-03-08: confirm owner shell, tab set, and deep-link behavior.

View File

@@ -7,32 +7,42 @@ Web
VERIFIED
## Description
Full CRUD UI for managing identity watchlist entries (issuer, SAN, keyId) with match modes (Exact, Prefix, Glob, Regex), severity levels, scope (Tenant/Global/System), alert viewing, pattern testing, and duplicate suppression configuration. Users can create, edit, delete, enable/disable watchlist entries and view resulting alerts.
Mounted Trust & Signing shell for managing identity watchlist entries (issuer, SAN, keyId) with match modes (Exact, Prefix, Glob, Regex), severity levels, scope (Tenant/Global/System), alert viewing, pattern testing, duplicate suppression configuration, and deep-link handoff from Mission Control and Notifications.
## Implementation Details
- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/watchlist/`
- **Components**:
- `watchlist-page` (`src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts`)
- **Source**: Feature matrix scan
- **Canonical routes**:
- `/setup/trust-signing/watchlist/entries`
- `/setup/trust-signing/watchlist/alerts`
- `/setup/trust-signing/watchlist/tuning`
- **Secondary entry points**:
- `Mission Control > Alerts`
- `Ops > Notifications`
- **Source**: shipped Trust & Signing watchlist shell
## E2E Test Plan
- **Setup**:
- [ ] Log in with a user that has appropriate permissions
- [ ] Navigate to `/security`
- [ ] Ensure test data exists (scanned artifacts, SBOM data, or seed data as needed)
- [ ] Navigate to `/setup/trust-signing/watchlist/entries`
- [ ] Ensure identity watchlist seed data exists for entries and recent alerts
- **Core verification**:
- [ ] Verify the list/table loads with paginated data
- [ ] Verify sorting and filtering controls work correctly
- [ ] Verify clicking a row navigates to the detail view
- [ ] Verify `Entries`, `Alerts`, and `Tuning` load inside one mounted shell
- [ ] Verify entry CRUD, pattern testing, and scope switching work
- [ ] Verify alert drill-in, jump-to-rule, and notifications handoff work
- **Edge cases**:
- [ ] Verify graceful handling when backend API is unavailable (error state)
- [ ] Verify `returnTo` preserves operator context from Mission Control and Notifications
- [ ] Verify responsive layout at different viewport sizes
- [ ] Verify accessibility (keyboard navigation, screen reader labels, ARIA attributes)
## Verification
- Run: `docs/qa/feature-checks/runs/web/identity-watchlist-management-ui/run-001/`
- Run:
- `npx ng test --watch=false --include src/tests/watchlist/identity-watchlist-management-ui.component.spec.ts --include src/tests/trust_admin/trust-scoring-dashboard-ui.behavior.spec.ts --include src/tests/notify/notify-watchlist-handoff.spec.ts`
- `npx playwright test tests/e2e/watchlist-shell.spec.ts --workers=1`
- Tier 0 (source): pass (`tier0-source-check.json`)
- Tier 1 (build/tests): pass (`tier1-build-check.json`)
- Tier 2 (behavior): pass (`tier2-e2e-check.json`)
- Verified on (UTC): 2026-02-11T07:02:25Z
- Verified on (UTC): 2026-03-07T16:43:00Z

View File

@@ -9,7 +9,6 @@
- `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md`
- `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md`
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md`
- `docs/implplan/SPRINT_20260307_024_FE_identity_watchlist_shell.md`
- `docs/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md`
- `docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md`
- `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
@@ -72,12 +71,12 @@
- [TODO] FE-PD-005 FE implementation slices for Decisioning Studio shell and cutover
- [TODO] FE-PD-006 QA and rollout contract for Decisioning Studio
- [TODO] FE-PD-007 Docs and deprecation plan for legacy policy / VEX product labels
- [TODO] FE-WL-001 Freeze Watchlist shell ownership and route contract
- [TODO] FE-WL-002 Entries tab list-detail implementation slice
- [TODO] FE-WL-003 Alerts tab and alert-detail drill-in
- [TODO] FE-WL-004 Tuning tab and operational diagnostics
- [TODO] FE-WL-005 Cross-product surfacing and deep links for Watchlist
- [TODO] FE-WL-006 QA, rollout, and docs sync for Watchlist
- [DONE] FE-WL-001 Freeze Watchlist shell ownership and route contract
- [DONE] FE-WL-002 Entries tab list-detail implementation slice
- [DONE] FE-WL-003 Alerts tab and alert-detail drill-in
- [DONE] FE-WL-004 Tuning tab and operational diagnostics
- [DONE] FE-WL-005 Cross-product surfacing and deep links for Watchlist
- [DONE] FE-WL-006 QA, rollout, and docs sync for Watchlist
- [TODO] FE-RW-001 Freeze reachability shell tabs and route contract
- [TODO] FE-RW-002 Witnesses tab and witness-detail page slice
- [TODO] FE-RW-003 PoE drawer and permalink route contract

View File

@@ -41,7 +41,7 @@ The order is by confidence that the capability should exist in the final Stella
- `Setup > Trust & Signing > Identity Watchlist`
- Notes:
- Detailed UX dossier: `docs/modules/ui/watchlist-operations/README.md`
- Implementation sprint: `docs/implplan/SPRINT_20260307_024_FE_identity_watchlist_shell.md`
- Implementation sprint: `docs-archived/implplan/SPRINT_20260307_024_FE_identity_watchlist_shell.md`
### 3. Reachability Witnessing
- Type: `merge`

View File

@@ -13,7 +13,6 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components.
- `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation.
- `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself.
- `SPRINT_20260307_024_FE_identity_watchlist_shell.md` - ship the Trust & Signing-owned identity watchlist shell with usable entries, alerts, tuning, and alert deep-link behavior.
- `SPRINT_20260307_025_FE_reachability_witnessing_merge.md` - ship witness and proof-of-exposure UX inside Security > Reachability with working cross-shell deep links.
- `SPRINT_20260307_026_FE_platform_ops_consolidation.md` - ship one Operations shell with grouped overview cards, legacy widget absorption, and legacy redirects.
- `SPRINT_20260307_027_FE_triage_explainability_workspace.md` - ship the artifact workspace lane model, explainability panels, and audit-bundle flows.
@@ -27,6 +26,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- `docs/modules/ui/policy-decisioning-studio/README.md` - proposed Decisioning Studio product shape, tab model, route contract, and Release Orchestrator integration boundary.
- `docs/modules/ui/restoration-topics/README.md` - detailed placement notes for the next restoration topics after Decisioning Studio.
- `docs/modules/ui/watchlist-operations/README.md` - detailed watchlist UX dossier and owner-shell contract.
- `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs.
- `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract.
- `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan.
- `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier.

View File

@@ -27,7 +27,7 @@ It answers four questions for each topic:
## Implementation Sprint Set
- `docs/implplan/SPRINT_20260307_024_FE_identity_watchlist_shell.md`
- `docs-archived/implplan/SPRINT_20260307_024_FE_identity_watchlist_shell.md` - shipped watchlist restoration
- `docs/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md`
- `docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md`
- `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`

View File

@@ -84,7 +84,7 @@ Merge these current behaviors into the new shell:
## Detailed UX And Sprint
- Detailed UX dossier: `../watchlist-operations/README.md`
- Implementation sprint: `../../../implplan/SPRINT_20260307_024_FE_identity_watchlist_shell.md`
- Implementation sprint: `../../../docs-archived/implplan/SPRINT_20260307_024_FE_identity_watchlist_shell.md`
## Corroborating Inputs

View File

@@ -1,5 +1,17 @@
# Identity Watchlist
## Implementation Status
- Status: `shipped`
- Owner shell: `Setup > Trust & Signing`
- Canonical routes:
- `/setup/trust-signing/watchlist/entries`
- `/setup/trust-signing/watchlist/alerts`
- `/setup/trust-signing/watchlist/tuning`
- Secondary entry points:
- `Mission Control > Alerts` deep-links into `Alerts`
- `Ops > Notifications` deep-links into `Tuning` and `Alerts`
## Recommendation
Restore Watchlist as a narrow operational shell owned by `Setup > Trust & Signing`, not as a standalone top-level product.

View File

@@ -15,6 +15,14 @@ import { RouterLink } from '@angular/router';
<ul>
<li><a routerLink="/releases/approvals" queryParamsHandling="merge">3 approvals blocked by policy gate evidence freshness</a></li>
<li>
<a
routerLink="/setup/trust-signing/watchlist/alerts"
[queryParams]="{ alertId: 'alert-001', returnTo: '/mission-control/alerts', scope: 'tenant', tab: 'alerts' }"
>
Identity watchlist alert requires signer review
</a>
</li>
<li><a routerLink="/security/disposition" queryParamsHandling="merge">2 waivers expiring within 24h</a></li>
<li><a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge">Feed freshness degraded for advisory ingest</a></li>
</ul>

View File

@@ -15,6 +15,31 @@
</button>
</header>
<section class="notify-card">
<header class="notify-card__header">
<div>
<h2>Watchlist handoff</h2>
<p>Identity watchlist configuration stays under Trust &amp; Signing. Use notifications only for delivery health and escalation outcomes.</p>
</div>
</header>
<div class="notify-actions">
<a
class="ghost-button"
routerLink="/setup/trust-signing/watchlist/tuning"
[queryParams]="{ returnTo: '/ops/operations/notifications', scope: 'tenant', tab: 'tuning' }"
>
Open watchlist tuning
</a>
<a
class="ghost-button"
routerLink="/setup/trust-signing/watchlist/alerts"
[queryParams]="{ returnTo: '/ops/operations/notifications', scope: 'tenant', tab: 'alerts' }"
>
Review watchlist alerts
</a>
</div>
</section>
<div class="notify-grid">
<article class="notify-card">
<header class="notify-card__header">

View File

@@ -63,4 +63,19 @@ describe('NotifyPanelComponent', () => {
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
expect(preview).toBeTruthy();
});
it('surfaces watchlist handoff links for tuning and alerts', () => {
const text = fixture.nativeElement.textContent as string;
const links = Array.from(
fixture.nativeElement.querySelectorAll('a')
) as HTMLAnchorElement[];
expect(text).toContain('Watchlist handoff');
expect(
links.some((link) => link.getAttribute('href')?.includes('/setup/trust-signing/watchlist/tuning'))
).toBeTrue();
expect(
links.some((link) => link.getAttribute('href')?.includes('/setup/trust-signing/watchlist/alerts'))
).toBeTrue();
});
});

View File

@@ -12,6 +12,7 @@ import {
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
@@ -40,7 +41,7 @@ type DeliveryFilter =
@Component({
selector: 'app-notify-panel',
imports: [CommonModule, ReactiveFormsModule],
imports: [CommonModule, ReactiveFormsModule, RouterLink],
templateUrl: './notify-panel.component.html',
styleUrls: ['./notify-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,85 +1,100 @@
/**
* @file trust-admin.component.spec.ts
* @sprint SPRINT_20251229_018c_FE
* @description Unit tests for TrustAdminComponent
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { of } from 'rxjs';
import { TRUST_API, type TrustApi } from '../../core/api/trust.client';
import type { TrustDashboardSummary } from '../../core/api/trust.models';
import { TrustAdminComponent } from './trust-admin.component';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import { TrustDashboardStats } from '../../core/api/trust.models';
describe('TrustAdminComponent', () => {
let component: TrustAdminComponent;
let fixture: ComponentFixture<TrustAdminComponent>;
let mockTrustApi: jasmine.SpyObj<TrustApi>;
let router: Router;
let trustApi: jasmine.SpyObj<TrustApi>;
const mockStats: TrustDashboardStats = {
totalKeys: 10,
activeKeys: 8,
expiringKeys: 2,
revokedKeys: 0,
totalIssuers: 25,
trustedIssuers: 20,
blockedIssuers: 5,
totalCertificates: 15,
validCertificates: 12,
expiringCertificates: 3,
recentAuditEvents: 50,
criticalEvents: 2,
const dashboardSummaryFixture: TrustDashboardSummary = {
keys: {
total: 12,
active: 9,
expiringSoon: 2,
expired: 1,
revoked: 0,
pendingRotation: 1,
},
issuers: {
total: 8,
fullTrust: 3,
partialTrust: 3,
minimalTrust: 1,
untrusted: 1,
blocked: 0,
averageTrustScore: 86.4,
},
certificates: {
total: 5,
valid: 4,
expiringSoon: 1,
expired: 0,
revoked: 0,
invalidChains: 0,
},
recentEvents: [],
expiryAlerts: [
{
keyId: 'key-001',
keyName: 'Attestation Key',
expiresAt: '2026-03-01T00:00:00Z',
daysUntilExpiry: 19,
severity: 'warning',
purpose: 'attestation',
suggestedAction: 'Rotate key',
},
],
};
beforeEach(async () => {
mockTrustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
'getDashboardStats',
trustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
'getDashboardSummary',
]);
mockTrustApi.getDashboardStats.and.returnValue(of(mockStats));
trustApi.getDashboardSummary.and.returnValue(of(dashboardSummaryFixture));
await TestBed.configureTestingModule({
imports: [TrustAdminComponent],
providers: [
provideRouter([]),
{ provide: TRUST_API, useValue: mockTrustApi },
{ provide: TRUST_API, useValue: trustApi },
],
}).compileComponents();
router = TestBed.inject(Router);
fixture = TestBed.createComponent(TrustAdminComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
it('loads the trust dashboard summary on init', () => {
fixture.detectChanges();
expect(trustApi.getDashboardSummary).toHaveBeenCalledTimes(1);
expect(component.summary()).toEqual(dashboardSummaryFixture);
expect(component.alertCount()).toBe(1);
});
it('should load dashboard stats on init', fakeAsync(() => {
it('renders the watchlist tab in the trust shell', () => {
fixture.detectChanges();
tick();
const text = fixture.nativeElement.textContent as string;
expect(mockTrustApi.getDashboardStats).toHaveBeenCalled();
expect(component.stats()).toEqual(mockStats);
}));
it('should display navigation tabs', () => {
fixture.detectChanges();
const tabs = fixture.nativeElement.querySelectorAll('.trust-admin__tabs a, .trust-admin__tabs button');
expect(tabs.length).toBeGreaterThan(0);
expect(text).toContain('Watchlist');
expect(text).toContain('Trust Management');
});
it('should display summary cards', fakeAsync(() => {
fixture.detectChanges();
tick();
it('derives the watchlist tab from nested watchlist routes', () => {
Object.defineProperty(router, 'url', {
configurable: true,
get: () => '/setup/trust-signing/watchlist/alerts?scope=tenant',
});
const cards = fixture.nativeElement.querySelectorAll('.summary-card, .stat-card, .dashboard-card');
// Should have summary cards for keys, issuers, certificates
expect(cards.length).toBeGreaterThan(0);
}));
it('should handle loading state', () => {
expect(component.loading()).toBeTrue();
fixture.detectChanges();
expect(component.loading()).toBeFalse();
expect(component.activeTab()).toBe('watchlist');
});
});

View File

@@ -13,8 +13,25 @@ import { filter } from 'rxjs';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import { TrustDashboardSummary } from '../../core/api/trust.models';
export type TrustAdminTab = 'keys' | 'issuers' | 'certificates' | 'audit' | 'airgap' | 'incidents' | 'analytics';
const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = ['keys', 'issuers', 'certificates', 'audit', 'airgap', 'incidents', 'analytics'];
export type TrustAdminTab =
| 'keys'
| 'issuers'
| 'certificates'
| 'watchlist'
| 'audit'
| 'airgap'
| 'incidents'
| 'analytics';
const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
'keys',
'issuers',
'certificates',
'watchlist',
'audit',
'airgap',
'incidents',
'analytics',
];
@Component({
selector: 'app-trust-admin',
@@ -28,7 +45,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = ['keys', 'issuers', 'certific
<p class="trust-admin__eyebrow">{{ workspaceLabel() }}</p>
<h1>Trust Management</h1>
<p class="trust-admin__lede">
Manage signing keys, trusted issuers, mTLS certificates, and view audit logs.
Manage signing keys, trusted issuers, mTLS certificates, identity watchlists, and audit logs.
</p>
</div>
<div class="trust-admin__actions">
@@ -135,6 +152,15 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = ['keys', 'issuers', 'certific
<span class="tab-badge tab-badge--warning">{{ summary()?.certificates?.expiringSoon }}</span>
}
</a>
<a
class="trust-admin__tab"
[class.trust-admin__tab--active]="activeTab() === 'watchlist'"
routerLink="watchlist"
role="tab"
[attr.aria-selected]="activeTab() === 'watchlist'"
>
Watchlist
</a>
<a
class="trust-admin__tab"
[class.trust-admin__tab--active]="activeTab() === 'audit'"
@@ -456,7 +482,9 @@ export class TrustAdminComponent implements OnInit {
private setActiveTabFromUrl(url: string): void {
const segments = url.split('?')[0].split('/').filter(Boolean);
const routeRoot = segments[0];
const path = segments.at(-1) ?? 'keys';
const path = segments.includes('watchlist')
? 'watchlist'
: segments.at(-1) ?? 'keys';
this.workspaceLabel.set(routeRoot === 'administration' ? 'Administration' : 'Setup');

View File

@@ -59,6 +59,38 @@ export const trustAdminRoutes: Routes = [
),
data: { requiredScopes: [StellaOpsScopes.SIGNER_READ] },
},
{
path: 'watchlist',
loadComponent: () =>
import('../watchlist/watchlist-page.component').then(
(m) => m.WatchlistPageComponent
),
data: { requiredScopes: [StellaOpsScopes.SIGNER_READ] },
},
{
path: 'watchlist/entries',
loadComponent: () =>
import('../watchlist/watchlist-page.component').then(
(m) => m.WatchlistPageComponent
),
data: { requiredScopes: [StellaOpsScopes.SIGNER_READ] },
},
{
path: 'watchlist/alerts',
loadComponent: () =>
import('../watchlist/watchlist-page.component').then(
(m) => m.WatchlistPageComponent
),
data: { requiredScopes: [StellaOpsScopes.SIGNER_READ] },
},
{
path: 'watchlist/tuning',
loadComponent: () =>
import('../watchlist/watchlist-page.component').then(
(m) => m.WatchlistPageComponent
),
data: { requiredScopes: [StellaOpsScopes.SIGNER_READ] },
},
{
path: 'audit',
loadComponent: () =>

View File

@@ -1,333 +1,689 @@
<div class="watchlist-page" data-testid="watchlist-page">
<header class="page-header">
<h1>Identity Watchlist</h1>
<p class="subtitle">Monitor signing identities in transparency logs</p>
<div>
<p class="eyebrow">Trust &amp; Signing</p>
<h1>Identity Watchlist</h1>
<p class="subtitle">
Monitor signer identities, triage watchlist alerts, and tune dedup or routing controls from the trust shell.
</p>
<p class="mode">{{ currentModeLabel() }}</p>
</div>
<div class="header-actions">
@if (returnTo()) {
<button type="button" class="btn-secondary" (click)="returnToSource()">
Return to {{ returnToLabel() }}
</button>
}
<button type="button" class="btn-secondary" (click)="refreshCurrentTab()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
</header>
<!-- Message Banner -->
@if (message()) {
<div class="message-banner" [class.success]="messageType() === 'success'" [class.error]="messageType() === 'error'">
{{ message() }}
<button class="dismiss" (click)="message.set(null)">&times;</button>
<div
class="message-banner"
[class.success]="messageType() === 'success'"
[class.error]="messageType() === 'error'"
>
<span>{{ message() }}</span>
<button type="button" class="dismiss" (click)="message.set(null)" aria-label="Dismiss message">
&times;
</button>
</div>
}
<!-- Navigation Tabs -->
<nav class="tabs">
<section class="context-bar">
<div class="scope-switcher" role="tablist" aria-label="Watchlist scope">
@for (scope of scopeOptions; track scope) {
<button
type="button"
class="scope-pill"
[class.scope-pill--active]="scopeFilter() === scope"
(click)="changeScope(scope)"
>
{{ scopeLabel(scope) }}
</button>
}
</div>
<div class="summary-grid">
<article class="summary-card">
<span class="summary-label">Enabled rules</span>
<strong class="summary-value">{{ enabledEntryCount() }}</strong>
<span class="summary-detail">{{ entryCount() }} visible in this scope</span>
</article>
<article class="summary-card">
<span class="summary-label">Alerts in 24h</span>
<strong class="summary-value">{{ alertsLast24hCount() }}</strong>
<span class="summary-detail">Recent matches routed into Mission Control</span>
</article>
<article class="summary-card">
<span class="summary-label">Dedup coverage</span>
<strong class="summary-value">{{ dedupCoveragePercent() }}%</strong>
<span class="summary-detail">Average window {{ averageDedupWindow() }} minutes</span>
</article>
<article class="summary-card">
<span class="summary-label">Routing overrides</span>
<strong class="summary-value">{{ channelOverrideCoverage() }}%</strong>
<span class="summary-detail">{{ regexRuleCount() }} regex-heavy rules to review</span>
</article>
</div>
</section>
<nav class="tabs" aria-label="Watchlist tabs">
<button
[class.active]="viewMode() === 'list'"
(click)="showList()">
Watchlist Entries
type="button"
data-testid="watchlist-tab-entries"
[class.active]="activeTab() === 'entries'"
(click)="showList()"
>
Entries
</button>
<button
[class.active]="viewMode() === 'alerts'"
(click)="showAlerts()">
Recent Alerts
type="button"
data-testid="watchlist-tab-alerts"
[class.active]="activeTab() === 'alerts'"
(click)="showAlerts()"
>
Alerts
</button>
<button
type="button"
data-testid="watchlist-tab-tuning"
[class.active]="activeTab() === 'tuning'"
(click)="showTuning()"
>
Tuning
</button>
</nav>
<!-- List View -->
@if (viewMode() === 'list') {
<section class="list-section">
<div class="toolbar">
<button class="btn-primary" data-testid="create-entry-btn" (click)="createNew()">
@if (activeTab() === 'entries') {
<section class="workspace">
<div class="workspace-toolbar">
<button type="button" class="btn-primary" data-testid="create-entry-btn" (click)="createNew()">
+ New Entry
</button>
<div class="filters">
<label>
<input
type="checkbox"
[checked]="includeGlobal()"
(change)="includeGlobal.set(!includeGlobal()); onFilterChange()">
Include Global
</label>
<label>
<input
type="checkbox"
[checked]="enabledOnly()"
(change)="enabledOnly.set(!enabledOnly()); onFilterChange()">
Enabled Only
</label>
<select
[value]="severityFilter()"
(change)="severityFilter.set($any($event.target).value); onFilterChange()">
<option value="">All Severities</option>
<option value="Info">Info</option>
<option value="Warning">Warning</option>
<option value="Critical">Critical</option>
<label class="field-inline field-inline--search">
<span>Search</span>
<input
type="search"
[value]="searchTerm()"
placeholder="Filter rules, issuers, SANs, key IDs"
(input)="onEntrySearch($any($event.target).value)"
/>
</label>
<label class="field-inline">
<input type="checkbox" [checked]="enabledOnly()" (change)="enabledOnly.set(!enabledOnly())" />
<span>Enabled only</span>
</label>
<label class="field-inline">
<span>Severity</span>
<select [value]="severityFilter()" (change)="severityFilter.set($any($event.target).value)">
<option value="">All severities</option>
@for (severity of severities; track severity) {
<option [value]="severity">{{ severity }}</option>
}
</select>
</div>
</label>
</div>
@if (loading()) {
<div class="loading">Loading...</div>
} @else if (filteredEntries().length === 0) {
<div class="empty-state">
<p>No watchlist entries found.</p>
<button class="btn-primary" (click)="createNew()">Create your first entry</button>
</div>
} @else {
<table class="entries-table">
<thead>
<tr>
<th>Name</th>
<th>Pattern</th>
<th>Match Mode</th>
<th>Scope</th>
<th>Severity</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (entry of filteredEntries(); track trackByEntry) {
<tr data-testid="entry-row">
<td class="name-cell">
<strong>{{ entry.displayName }}</strong>
@if (entry.description) {
<span class="description">{{ entry.description }}</span>
}
</td>
<td class="pattern-cell">
@if (entry.issuer) {
<div class="pattern"><span class="label">Issuer:</span> {{ entry.issuer }}</div>
}
@if (entry.subjectAlternativeName) {
<div class="pattern"><span class="label">SAN:</span> {{ entry.subjectAlternativeName }}</div>
}
@if (entry.keyId) {
<div class="pattern"><span class="label">KeyId:</span> {{ entry.keyId }}</div>
}
</td>
<td>
<span class="badge badge-mode">{{ entry.matchMode }}</span>
</td>
<td>
<span class="badge badge-scope">{{ entry.scope }}</span>
</td>
<td>
<span class="badge" [class]="getSeverityClass(entry.severity)">
{{ entry.severity }}
</span>
</td>
<td>
<button
class="toggle-btn"
[class.enabled]="entry.enabled"
(click)="toggleEnabled(entry)">
{{ entry.enabled ? 'Enabled' : 'Disabled' }}
</button>
</td>
<td class="actions-cell">
<button class="btn-icon" data-testid="edit-entry-btn" title="Edit" (click)="editEntry(entry)">Edit</button>
<button class="btn-icon btn-danger" data-testid="delete-entry-btn" title="Delete" (click)="deleteEntry(entry)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
<div class="table-footer">
{{ entryCount() }} entries
</div>
}
</section>
}
<!-- Edit View -->
@if (viewMode() === 'edit') {
<section class="edit-section">
<h2>{{ selectedEntry() ? 'Edit Entry' : 'New Entry' }}</h2>
<form [formGroup]="entryForm" (ngSubmit)="saveEntry()" data-testid="entry-form">
<div class="form-group">
<label for="displayName">Display Name *</label>
<input id="displayName" formControlName="displayName" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" formControlName="description" rows="2"></textarea>
</div>
<fieldset class="identity-fields">
<legend>Identity Patterns (at least one required)</legend>
<div class="form-group">
<label for="issuer">OIDC Issuer</label>
<input id="issuer" formControlName="issuer" placeholder="https://token.actions.githubusercontent.com">
</div>
<div class="form-group">
<label for="san">Subject Alternative Name</label>
<input id="san" formControlName="subjectAlternativeName" placeholder="*@example.com">
</div>
<div class="form-group">
<label for="keyId">Key ID</label>
<input id="keyId" formControlName="keyId" placeholder="key-abc-123">
</div>
</fieldset>
<div class="form-row">
<div class="form-group">
<label for="matchMode">Match Mode</label>
<select id="matchMode" formControlName="matchMode">
@for (mode of matchModes; track mode) {
<option [value]="mode">{{ getMatchModeLabel(mode) }}</option>
}
</select>
</div>
<div class="form-group">
<label for="scope">Scope</label>
<select id="scope" formControlName="scope">
@for (scope of scopes; track scope) {
<option [value]="scope">{{ scope }}</option>
}
</select>
</div>
<div class="form-group">
<label for="severity">Alert Severity</label>
<select id="severity" formControlName="severity">
@for (sev of severities; track sev) {
<option [value]="sev">{{ sev }}</option>
}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="suppressDuplicates">Suppress Duplicates (minutes)</label>
<input id="suppressDuplicates" type="number" formControlName="suppressDuplicatesMinutes" min="0">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" formControlName="enabled">
Enabled
</label>
</div>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated)</label>
<input id="tags" formControlName="tagsText" placeholder="ci, production, critical">
</div>
<div class="form-group">
<label for="channels">Channel Overrides (one per line)</label>
<textarea id="channels" formControlName="channelOverridesText" rows="2" placeholder="slack:security-alerts"></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn-secondary" (click)="showList()">Cancel</button>
<button type="submit" class="btn-primary" [disabled]="loading()">
{{ selectedEntry() ? 'Update' : 'Create' }}
</button>
</div>
</form>
<!-- Test Pattern Section (only for existing entries) -->
@if (selectedEntry()) {
<div class="test-section">
<h3>Test Pattern</h3>
<form [formGroup]="testForm" (ngSubmit)="testPattern()">
<div class="form-row">
<div class="form-group">
<label for="testIssuer">Test Issuer</label>
<input id="testIssuer" formControlName="issuer">
</div>
<div class="form-group">
<label for="testSan">Test SAN</label>
<input id="testSan" formControlName="subjectAlternativeName">
</div>
<div class="form-group">
<label for="testKeyId">Test Key ID</label>
<input id="testKeyId" formControlName="keyId">
</div>
<button type="submit" class="btn-secondary" [disabled]="loading()">Test</button>
</div>
</form>
@if (testResult()) {
<div class="test-result" data-testid="test-result" [class.match]="testResult()!.matches" [class.no-match]="!testResult()!.matches">
@if (testResult()!.matches) {
<strong>Match!</strong>
Score: {{ testResult()!.matchScore }} |
Fields: {{ testResult()!.matchedFields.join(', ') }}
} @else {
<strong>No match</strong>
}
<div class="workspace-grid" [class.workspace-grid--detail]="!!entryPanelMode()">
<div class="workspace-primary">
@if (entriesLoading() && !entries().length) {
<div class="empty-state">Loading watchlist rules...</div>
} @else if (!filteredEntries().length) {
<div class="empty-state">
<p>No watchlist rules match this scope and filter set.</p>
<button type="button" class="btn-primary" (click)="createNew()">Create your first rule</button>
</div>
} @else {
<table class="data-table entries-table">
<thead>
<tr>
<th>Name</th>
<th>Pattern</th>
<th>Mode</th>
<th>Scope</th>
<th>Severity</th>
<th>Alerts</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (entry of filteredEntries(); track trackByEntry($index, entry)) {
<tr data-testid="entry-row">
<td class="name-cell">
<strong>{{ entry.displayName }}</strong>
@if (entry.description) {
<span class="description">{{ entry.description }}</span>
}
</td>
<td class="pattern-cell">
@if (entry.issuer) {
<div><span class="label">Issuer</span> {{ entry.issuer }}</div>
}
@if (entry.subjectAlternativeName) {
<div><span class="label">SAN</span> {{ entry.subjectAlternativeName }}</div>
}
@if (entry.keyId) {
<div><span class="label">Key ID</span> {{ entry.keyId }}</div>
}
</td>
<td><span class="badge badge-mode">{{ entry.matchMode }}</span></td>
<td><span class="badge badge-scope">{{ entry.scope }}</span></td>
<td>
<span class="badge" [class]="getSeverityClass(entry.severity)">
{{ entry.severity }}
</span>
</td>
<td>
<div class="metric-cell">
<strong>{{ alertCountForEntry(entry.id) }}</strong>
@if (latestAlertForEntry(entry.id); as latestAlert) {
<span>{{ formatDate(latestAlert) }}</span>
} @else {
<span>No recent matches</span>
}
</div>
</td>
<td>{{ formatDate(entry.updatedAt) }}</td>
<td class="actions-cell">
<button type="button" class="btn-icon" data-testid="edit-entry-btn" (click)="editEntry(entry)">
Edit
</button>
<button type="button" class="btn-icon" data-testid="duplicate-entry-btn" (click)="duplicateEntry(entry)">
Duplicate
</button>
<button type="button" class="btn-icon" (click)="toggleEnabled(entry)">
{{ entry.enabled ? 'Disable' : 'Enable' }}
</button>
<button type="button" class="btn-icon" (click)="openTuningForEntry(entry)">
Tune
</button>
<button type="button" class="btn-icon btn-danger" data-testid="delete-entry-btn" (click)="deleteEntry(entry)">
Delete
</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
}
@if (entryPanelMode()) {
<aside class="detail-panel" data-testid="entry-detail">
<header class="detail-panel__header">
<div>
<p class="detail-eyebrow">Entries</p>
<h2>
{{
entryPanelMode() === 'edit'
? 'Edit rule'
: entryPanelMode() === 'duplicate'
? 'Duplicate rule'
: 'Create rule'
}}
</h2>
<p>Keep pattern design, dedup, and routing decisions inside the trust-owned shell.</p>
</div>
<button type="button" class="btn-icon" (click)="closeEntryPanel()">Close</button>
</header>
<form [formGroup]="entryForm" (ngSubmit)="saveEntry()" data-testid="entry-form" class="detail-form">
<label class="field-block">
<span>Display name</span>
<input formControlName="displayName" type="text" required />
</label>
<label class="field-block">
<span>Description</span>
<textarea formControlName="description" rows="2"></textarea>
</label>
<div class="field-group">
<label class="field-block">
<span>OIDC issuer</span>
<input formControlName="issuer" type="text" placeholder="https://token.actions.githubusercontent.com" />
</label>
<label class="field-block">
<span>Subject alternative name</span>
<input formControlName="subjectAlternativeName" type="text" placeholder="repo:org/app:*" />
</label>
</div>
<label class="field-block">
<span>Key ID</span>
<input formControlName="keyId" type="text" placeholder="key-abc-123" />
</label>
<div class="field-group">
<label class="field-block">
<span>Match mode</span>
<select formControlName="matchMode">
@for (mode of matchModes; track mode) {
<option [value]="mode">{{ getMatchModeLabel(mode) }}</option>
}
</select>
</label>
<label class="field-block">
<span>Scope</span>
<select formControlName="scope">
@for (scope of scopes; track scope) {
<option [value]="scope">{{ scope }}</option>
}
</select>
</label>
<label class="field-block">
<span>Severity</span>
<select formControlName="severity">
@for (severity of severities; track severity) {
<option [value]="severity">{{ severity }}</option>
}
</select>
</label>
</div>
<div class="field-group">
<label class="field-block">
<span>Dedup window (minutes)</span>
<input formControlName="suppressDuplicatesMinutes" type="number" min="0" />
</label>
<label class="field-inline">
<input formControlName="enabled" type="checkbox" />
<span>Rule enabled</span>
</label>
</div>
<label class="field-block">
<span>Channel overrides</span>
<textarea formControlName="channelOverridesText" rows="2" placeholder="slack:security\nemail:oncall"></textarea>
</label>
<label class="field-block">
<span>Tags</span>
<input formControlName="tagsText" type="text" placeholder="ci, production, signing" />
</label>
<div class="form-actions">
<button type="button" class="btn-secondary" (click)="closeEntryPanel()">Cancel</button>
<button type="submit" class="btn-primary" [disabled]="loading()">
{{
entryPanelMode() === 'edit'
? 'Save changes'
: entryPanelMode() === 'duplicate'
? 'Create duplicate'
: 'Create rule'
}}
</button>
</div>
</form>
<section class="test-section">
<header>
<h3>Pattern test</h3>
<p>Validate this rule with live sample identity values before it starts paging operators.</p>
</header>
@if (selectedEntry()) {
<form [formGroup]="testForm" (ngSubmit)="testPattern()">
<div class="field-group">
<label class="field-block">
<span>Test issuer</span>
<input formControlName="issuer" type="text" />
</label>
<label class="field-block">
<span>Test SAN</span>
<input formControlName="subjectAlternativeName" type="text" />
</label>
<label class="field-block">
<span>Test key ID</span>
<input formControlName="keyId" type="text" />
</label>
</div>
<div class="form-actions form-actions--compact">
<button type="submit" class="btn-secondary" [disabled]="loading()">Run test</button>
</div>
</form>
@if (testResult()) {
<div
class="test-result"
data-testid="test-result"
[class.match]="testResult()!.matches"
[class.no-match]="!testResult()!.matches"
>
@if (testResult()!.matches) {
<strong>Match</strong>
<span>Score {{ testResult()!.matchScore }} · Fields {{ testResult()!.matchedFields.join(', ') }}</span>
} @else {
<strong>No match</strong>
}
</div>
}
} @else {
<div class="inline-note">Save the draft once before running a live pattern test.</div>
}
</section>
</aside>
}
</div>
</section>
}
<!-- Alerts View -->
@if (viewMode() === 'alerts') {
<section class="alerts-section">
<div class="toolbar">
<button class="btn-secondary" (click)="loadAlerts()" [disabled]="loading()">
Refresh
</button>
@if (activeTab() === 'alerts') {
<section class="workspace">
<div class="workspace-toolbar">
<label class="field-inline field-inline--search">
<span>Search</span>
<input
type="search"
[value]="alertSearchTerm()"
placeholder="Filter by rule, identity, or Rekor UUID"
(input)="onAlertSearch($any($event.target).value)"
/>
</label>
<label class="field-inline">
<span>Severity</span>
<select [value]="alertSeverityFilter()" (change)="alertSeverityFilter.set($any($event.target).value)">
<option value="">All severities</option>
@for (severity of severities; track severity) {
<option [value]="severity">{{ severity }}</option>
}
</select>
</label>
<label class="field-inline">
<span>Window</span>
<select
data-testid="alerts-window-select"
[value]="alertWindow()"
(change)="alertWindow.set($any($event.target).value)"
>
@for (window of alertWindows; track window) {
<option [value]="window" [selected]="alertWindow() === window">{{ window }}</option>
}
</select>
</label>
<label class="field-inline">
<span>Order</span>
<select [value]="alertSortOrder()" (change)="alertSortOrder.set($any($event.target).value)">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</label>
</div>
@if (loading()) {
<div class="loading">Loading alerts...</div>
} @else if (alerts().length === 0) {
<div class="empty-state">
<p>No recent alerts.</p>
<div class="workspace-grid" [class.workspace-grid--detail]="!!selectedAlert()">
<div class="workspace-primary">
@if (alertsLoading() && !alerts().length) {
<div class="empty-state">Loading watchlist alerts...</div>
} @else if (!filteredAlerts().length) {
<div class="empty-state">No alerts match the current scope, window, and filter set.</div>
} @else {
<table class="data-table alerts-table">
<thead>
<tr>
<th>Time</th>
<th>Rule</th>
<th>Severity</th>
<th>Identity</th>
<th>Rekor</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (alert of filteredAlerts(); track trackByAlert($index, alert)) {
<tr data-testid="alert-row">
<td>{{ formatDate(alert.occurredAt) }}</td>
<td>{{ alert.watchlistEntryName }}</td>
<td>
<span class="badge" [class]="getSeverityClass(alert.severity)">
{{ alert.severity }}
</span>
</td>
<td class="pattern-cell">
@if (alert.matchedIssuer) {
<div><span class="label">Issuer</span> {{ alert.matchedIssuer }}</div>
}
@if (alert.matchedSan) {
<div><span class="label">SAN</span> {{ alert.matchedSan }}</div>
}
@if (alert.matchedKeyId) {
<div><span class="label">Key ID</span> {{ alert.matchedKeyId }}</div>
}
</td>
<td class="rekor-cell">
@if (alert.rekorLogIndex) {
<div>Index {{ alert.rekorLogIndex }}</div>
}
@if (alert.rekorUuid) {
<div class="uuid">{{ alert.rekorUuid }}</div>
}
</td>
<td class="actions-cell">
<button type="button" class="btn-icon" (click)="openAlertDetail(alert)">View</button>
<button type="button" class="btn-icon" (click)="jumpToEntry(alert.watchlistEntryId)">Rule</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
} @else {
<table class="alerts-table">
<thead>
<tr>
<th>Time</th>
<th>Entry</th>
<th>Severity</th>
<th>Matched Identity</th>
<th>Rekor</th>
</tr>
</thead>
<tbody>
@for (alert of alerts(); track trackByAlert) {
<tr data-testid="alert-row">
<td>{{ formatDate(alert.occurredAt) }}</td>
<td>{{ alert.watchlistEntryName }}</td>
<td>
<span class="badge" [class]="getSeverityClass(alert.severity)">
{{ alert.severity }}
</span>
</td>
<td class="identity-cell">
@if (alert.matchedIssuer) {
<div><span class="label">Issuer:</span> {{ alert.matchedIssuer }}</div>
@if (selectedAlert(); as alert) {
<aside class="detail-panel" data-testid="alert-detail">
<header class="detail-panel__header">
<div>
<p class="detail-eyebrow">Alert detail</p>
<h2>{{ alert.watchlistEntryName }}</h2>
<p>Investigate the signer identity, then jump back into the owning watchlist rule or tuning controls.</p>
</div>
<button type="button" class="btn-icon" (click)="closeAlertDetail()">Close</button>
</header>
<div class="detail-section">
<span class="badge" [class]="getSeverityClass(alert.severity)">{{ alert.severity }}</span>
<p><strong>Occurred</strong> {{ formatDate(alert.occurredAt) }}</p>
</div>
<div class="detail-section">
<h3>Matched identity</h3>
@if (alert.matchedIssuer) {
<p><span class="label">Issuer</span> {{ alert.matchedIssuer }}</p>
}
@if (alert.matchedSan) {
<p><span class="label">SAN</span> {{ alert.matchedSan }}</p>
}
@if (alert.matchedKeyId) {
<p><span class="label">Key ID</span> {{ alert.matchedKeyId }}</p>
}
</div>
<div class="detail-section">
<h3>Transparency evidence</h3>
@if (alert.rekorLogIndex) {
<p><span class="label">Log index</span> {{ alert.rekorLogIndex }}</p>
}
@if (alert.rekorUuid) {
<p><span class="label">Rekor UUID</span> <span class="uuid">{{ alert.rekorUuid }}</span></p>
}
</div>
<div class="detail-section">
<h3>Recent similar alerts</h3>
@if (similarAlerts().length) {
<ul class="similar-alerts">
@for (similar of similarAlerts(); track trackByAlert($index, similar)) {
<li>
<button type="button" class="text-button" (click)="openAlertDetail(similar)">
{{ formatDate(similar.occurredAt) }} · {{ similar.severity }}
</button>
</li>
}
@if (alert.matchedSan) {
<div><span class="label">SAN:</span> {{ alert.matchedSan }}</div>
}
@if (alert.matchedKeyId) {
<div><span class="label">KeyId:</span> {{ alert.matchedKeyId }}</div>
}
</td>
<td class="rekor-cell">
@if (alert.rekorLogIndex) {
<div>Index: {{ alert.rekorLogIndex }}</div>
}
@if (alert.rekorUuid) {
<div class="uuid">{{ alert.rekorUuid }}</div>
}
</td>
</tr>
}
</tbody>
</table>
}
</ul>
} @else {
<p>No similar alerts in the current filter window.</p>
}
</div>
<div class="form-actions">
<button type="button" class="btn-secondary" (click)="jumpToEntry(alert.watchlistEntryId)">
Jump to rule
</button>
@if (selectedEntry()) {
<button type="button" class="btn-secondary" (click)="openTuningForEntry(selectedEntry()!)">
Open tuning
</button>
}
</div>
</aside>
}
</div>
</section>
}
@if (activeTab() === 'tuning') {
<section class="tuning-layout">
<div class="tuning-left">
<div class="summary-grid summary-grid--compact">
<article class="summary-card">
<span class="summary-label">Top noisy rules</span>
<strong class="summary-value">{{ topNoisyRules().length }}</strong>
<span class="summary-detail">Rules with the highest recent alert concentration</span>
</article>
<article class="summary-card">
<span class="summary-label">Average dedup window</span>
<strong class="summary-value">{{ averageDedupWindow() }}m</strong>
<span class="summary-detail">Increase when alert storms dominate operator noise</span>
</article>
</div>
<section class="tuning-card">
<header class="tuning-card__header">
<div>
<h2>Noisy rules</h2>
<p>Prioritize the rules creating the most operator load and tighten their dedup or routing.</p>
</div>
</header>
@if (topNoisyRules().length) {
<table class="data-table">
<thead>
<tr>
<th>Rule</th>
<th>Alerts</th>
<th>Last alert</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (item of topNoisyRules(); track item.entry.id) {
<tr>
<td>
<strong>{{ item.entry.displayName }}</strong>
<div class="description">{{ tuningChannelSummary(item.entry) }}</div>
</td>
<td>{{ item.alertCount }} total · {{ item.recentAlertCount }} in 24h</td>
<td>{{ item.lastAlertAt ? formatDate(item.lastAlertAt) : 'No matches' }}</td>
<td class="actions-cell">
<button type="button" class="btn-icon" (click)="selectTuningTarget(item.entry.id)">Tune</button>
<button type="button" class="btn-icon" (click)="editEntry(item.entry)">Edit rule</button>
</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="empty-state">No rule has produced alerts in the current scope yet.</div>
}
</section>
<section class="tuning-card">
<header class="tuning-card__header">
<div>
<h2>Runbook guidance</h2>
<p>Use the same operational language as the backend runbook when deciding whether to tune, disable, or reroute a rule.</p>
</div>
</header>
<div class="guidance-grid">
<article>
<h3>High alert volume</h3>
<p>Narrow the pattern, raise the dedup window, or lower severity if the rule is too broad.</p>
</article>
<article>
<h3>High scan latency</h3>
<p>Review regex-heavy rules first and replace them with prefix or exact matches when possible.</p>
</article>
<article>
<h3>Channel health</h3>
<p>Use explicit channel overrides only for the rules that must bypass the default notification route.</p>
</article>
</div>
</section>
</div>
<aside class="detail-panel detail-panel--sticky">
@if (tuningTarget(); as entry) {
<header class="detail-panel__header">
<div>
<p class="detail-eyebrow">Tuning target</p>
<h2>{{ entry.displayName }}</h2>
<p>Adjust dedup, severity, and routing controls without leaving the watchlist shell.</p>
</div>
<button type="button" class="btn-icon" (click)="editEntry(entry)">Edit rule</button>
</header>
<div class="detail-section">
<span class="badge badge-scope">{{ entry.scope }}</span>
<span class="badge" [class]="getSeverityClass(entry.severity)">{{ entry.severity }}</span>
</div>
<form [formGroup]="tuningForm" (ngSubmit)="saveTuning()" data-testid="tuning-form" class="detail-form">
<label class="field-block">
<span>Dedup window (minutes)</span>
<input formControlName="suppressDuplicatesMinutes" type="number" min="0" />
</label>
<label class="field-block">
<span>Severity</span>
<select formControlName="severity">
@for (severity of severities; track severity) {
<option [value]="severity">{{ severity }}</option>
}
</select>
</label>
<label class="field-block">
<span>Channel overrides</span>
<textarea formControlName="channelOverridesText" rows="3" placeholder="slack:security\nemail:oncall"></textarea>
</label>
<label class="field-inline">
<input formControlName="enabled" type="checkbox" />
<span>Rule enabled</span>
</label>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="loading()">Save tuning</button>
</div>
</form>
} @else {
<div class="empty-state">
Pick a noisy rule or a visible entry to start tuning dedup and routing controls.
</div>
}
</aside>
</section>
}
</div>

View File

@@ -1,453 +1,478 @@
// -----------------------------------------------------------------------------
// watchlist-page.component.scss
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
// Task: WATCH-009
// Description: Styles for identity watchlist management page.
// -----------------------------------------------------------------------------
.watchlist-page {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
display: grid;
gap: 1.25rem;
padding: 1.25rem;
color: var(--color-text-primary);
}
.page-header {
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
h1 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--color-text-heading);
}
.eyebrow,
.detail-eyebrow {
margin: 0 0 0.35rem;
color: var(--color-status-info);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.subtitle {
margin: 0;
color: var(--color-text-muted);
font-size: 0.95rem;
}
h1,
h2,
h3 {
margin: 0;
color: var(--color-text-heading);
}
.subtitle,
.mode,
.detail-panel__header p,
.summary-detail,
.description,
.inline-note {
color: var(--color-text-secondary);
}
.mode {
margin: 0.5rem 0 0;
font-size: 0.9rem;
}
.header-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.message-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 4px;
align-items: center;
gap: 1rem;
padding: 0.85rem 1rem;
border-radius: 0.9rem;
border: 1px solid transparent;
&.success {
background: var(--color-status-success-bg);
border-color: var(--color-status-success-border);
color: var(--color-status-success-text);
border: 1px solid var(--color-status-success-border);
}
&.error {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
border: 1px solid var(--color-status-error-border);
}
.dismiss {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid var(--color-border-primary);
padding-bottom: 0;
button {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
font-size: 0.95rem;
color: var(--color-text-muted);
transition: all 0.2s;
&:hover {
color: var(--color-text-heading);
}
&.active {
color: var(--color-text-link);
border-bottom-color: var(--color-text-link);
font-weight: 500;
}
}
}
.toolbar {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
.filters {
display: flex;
align-items: center;
gap: 1rem;
margin-left: auto;
label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
cursor: pointer;
}
select {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: 4px;
font-size: 0.9rem;
}
}
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btn-primary {
background: var(--color-brand-secondary);
color: white;
border: 1px solid var(--color-brand-secondary);
&:hover:not(:disabled) {
background: var(--color-brand-primary-hover);
}
}
.btn-secondary {
background: var(--color-surface-primary);
color: var(--color-text-primary);
border: 1px solid var(--color-border-primary);
&:hover:not(:disabled) {
background: var(--color-surface-secondary);
}
}
.btn-icon {
padding: 0.25rem 0.5rem;
background: none;
border: 1px solid var(--color-border-primary);
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
&:hover {
background: var(--color-surface-secondary);
}
&.btn-danger:hover {
background: var(--color-status-error-bg);
border-color: var(--color-status-error-border);
color: var(--color-status-error-text);
}
}
.loading,
.empty-state {
text-align: center;
padding: 3rem;
color: var(--color-text-muted);
.dismiss,
.text-button,
.btn-icon,
.btn-primary,
.btn-secondary,
.scope-pill,
.tabs button {
cursor: pointer;
}
p {
margin-bottom: 1rem;
.dismiss,
.text-button,
.btn-icon {
background: transparent;
border: none;
}
.context-bar,
.workspace-primary,
.detail-panel,
.summary-card,
.tuning-card {
background: color-mix(in srgb, var(--color-surface-primary) 90%, transparent);
border: 1px solid var(--color-border-primary);
border-radius: 1rem;
}
.context-bar {
padding: 1rem;
display: grid;
gap: 1rem;
}
.scope-switcher {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.scope-pill {
padding: 0.55rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
&--active {
border-color: var(--color-status-info);
color: var(--color-status-info);
background: color-mix(in srgb, var(--color-status-info) 12%, transparent);
}
}
// Tables
.entries-table,
.alerts-table {
width: 100%;
border-collapse: collapse;
background: var(--color-surface-primary);
border-radius: 4px;
overflow: hidden;
box-shadow: var(--shadow-sm);
th,
td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
}
th {
background: var(--color-surface-secondary);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.025em;
color: var(--color-text-secondary);
}
tbody tr:hover {
background: var(--color-surface-secondary);
}
.summary-grid {
display: grid;
gap: 0.85rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.name-cell {
strong {
display: block;
margin-bottom: 0.25rem;
}
.description {
font-size: 0.85rem;
color: var(--color-text-muted);
}
.summary-grid--compact {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.pattern-cell {
.pattern {
font-family: monospace;
font-size: 0.85rem;
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
.label {
color: var(--color-text-muted);
font-weight: 500;
}
}
.summary-card {
padding: 1rem;
display: grid;
gap: 0.25rem;
}
.actions-cell {
white-space: nowrap;
.summary-label {
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.summary-value {
font-size: 1.5rem;
}
.tabs {
display: flex;
gap: 0.35rem;
padding-bottom: 0.15rem;
border-bottom: 1px solid var(--color-border-primary);
button {
margin-right: 0.5rem;
&:last-child {
margin-right: 0;
}
}
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
&.badge-mode {
background: var(--color-surface-tertiary);
padding: 0.8rem 1rem;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--color-text-secondary);
}
&.badge-scope {
background: var(--color-status-info-bg);
color: var(--color-text-link);
}
&.severity-critical {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
&.severity-warning {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
&.severity-info {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
}
.toggle-btn {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
border: 1px solid;
&.enabled {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
border-color: var(--color-status-success-border);
}
&:not(.enabled) {
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
border-color: var(--color-border-primary);
}
}
.table-footer {
padding: 0.75rem 1rem;
background: var(--color-surface-secondary);
font-size: 0.9rem;
color: var(--color-text-muted);
border-radius: 0 0 4px 4px;
}
// Edit Form
.edit-section {
background: var(--color-surface-primary);
border-radius: 4px;
padding: 1.5rem;
box-shadow: var(--shadow-sm);
h2 {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
}
}
.form-group {
margin-bottom: 1rem;
label {
display: block;
margin-bottom: 0.375rem;
font-weight: 500;
font-size: 0.9rem;
}
input,
textarea,
select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: 4px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
}
textarea {
resize: vertical;
min-height: 60px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
cursor: pointer;
input {
width: auto;
&.active {
color: var(--color-status-info);
border-bottom-color: var(--color-status-info);
font-weight: 600;
}
}
}
.form-row {
.workspace,
.tuning-layout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
align-items: end;
}
.identity-fields {
border: 1px solid var(--color-border-primary);
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
.workspace-toolbar {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
legend {
padding: 0 0.5rem;
font-weight: 500;
font-size: 0.9rem;
.workspace-grid {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr);
&--detail {
grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.95fr);
}
}
.workspace-primary {
overflow: hidden;
}
.tuning-layout {
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr);
align-items: start;
}
.tuning-left {
display: grid;
gap: 1rem;
}
.tuning-card {
padding: 1rem;
display: grid;
gap: 1rem;
}
.tuning-card__header,
.detail-panel__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.detail-panel {
padding: 1rem;
display: grid;
gap: 1rem;
}
.detail-panel--sticky {
position: sticky;
top: 1rem;
}
.detail-section,
.test-section {
display: grid;
gap: 0.55rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border-primary);
}
.detail-form,
.test-section form,
.guidance-grid {
display: grid;
gap: 0.85rem;
}
.field-group {
display: grid;
gap: 0.85rem;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.field-block,
.field-inline {
display: grid;
gap: 0.35rem;
}
.field-inline {
align-items: center;
grid-auto-flow: column;
justify-content: start;
}
.field-inline--search {
min-width: min(420px, 100%);
grid-auto-flow: row;
}
input,
textarea,
select {
width: 100%;
padding: 0.65rem 0.8rem;
border-radius: 0.75rem;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
textarea {
resize: vertical;
}
.btn-primary,
.btn-secondary,
.btn-icon {
padding: 0.6rem 0.9rem;
border-radius: 0.75rem;
font-weight: 600;
}
.btn-primary {
border: 1px solid var(--color-status-info);
background: var(--color-status-info);
color: #0b1220;
}
.btn-secondary,
.btn-icon {
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
.btn-danger {
color: var(--color-status-error-text);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border-primary);
flex-wrap: wrap;
}
// Test Section
.test-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border-primary);
.form-actions--compact {
padding-top: 0;
}
h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
.data-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.85rem 0.9rem;
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
vertical-align: top;
}
thead th {
background: color-mix(in srgb, var(--color-surface-secondary) 92%, transparent);
color: var(--color-text-secondary);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.name-cell strong,
.metric-cell strong {
display: block;
}
.metric-cell {
display: grid;
gap: 0.2rem;
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.pattern-cell .label,
.detail-section .label {
color: var(--color-text-secondary);
display: inline-block;
min-width: 4.5rem;
font-weight: 600;
}
.actions-cell {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.3rem 0.65rem;
font-size: 0.78rem;
font-weight: 600;
}
.badge-mode {
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
}
.badge-scope {
background: color-mix(in srgb, var(--color-status-info) 15%, transparent);
color: var(--color-status-info);
}
.severity-critical {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.severity-warning {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.severity-info {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
.rekor-cell .uuid,
.uuid {
font-family: monospace;
word-break: break-all;
}
.empty-state {
padding: 2.5rem 1.5rem;
display: grid;
gap: 0.8rem;
justify-items: center;
text-align: center;
color: var(--color-text-secondary);
}
.test-result {
padding: 0.75rem 1rem;
border-radius: 4px;
margin-top: 1rem;
display: grid;
gap: 0.25rem;
padding: 0.85rem 1rem;
border-radius: 0.85rem;
border: 1px solid var(--color-border-primary);
&.match {
background: var(--color-status-success-bg);
border-color: var(--color-status-success-border);
color: var(--color-status-success-text);
border: 1px solid var(--color-status-success-border);
}
&.no-match {
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border-primary);
}
}
// Alerts
.alerts-section {
.identity-cell,
.rekor-cell {
font-size: 0.85rem;
.similar-alerts {
display: grid;
gap: 0.4rem;
margin: 0;
padding-left: 1rem;
}
.label {
color: var(--color-text-muted);
font-weight: 500;
}
.text-button {
padding: 0;
color: var(--color-status-info);
text-align: left;
}
.uuid {
font-family: monospace;
color: var(--color-text-muted);
}
.guidance-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
@media (max-width: 1100px) {
.workspace-grid,
.workspace-grid--detail,
.tuning-layout {
grid-template-columns: minmax(0, 1fr);
}
.detail-panel--sticky {
position: static;
}
}
@media (max-width: 720px) {
.page-header {
grid-template-columns: minmax(0, 1fr);
display: grid;
}
.workspace-toolbar,
.header-actions,
.form-actions {
flex-direction: column;
align-items: stretch;
}
}

View File

@@ -1,14 +1,10 @@
// -----------------------------------------------------------------------------
// watchlist-page.component.spec.ts
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
// Task: WATCH-009
// Description: Unit tests for identity watchlist management page component.
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { WATCHLIST_API } from '../../core/api/watchlist.client';
import { WatchlistMockClient } from '../../core/api/watchlist.client';
import {
WATCHLIST_API,
WatchlistMockClient,
} from '../../core/api/watchlist.client';
import { WatchlistPageComponent } from './watchlist-page.component';
describe('WatchlistPageComponent', () => {
@@ -22,6 +18,7 @@ describe('WatchlistPageComponent', () => {
await TestBed.configureTestingModule({
imports: [WatchlistPageComponent],
providers: [
provideRouter([]),
{ provide: WATCHLIST_API, useValue: mockClient },
],
}).compileComponents();
@@ -29,230 +26,115 @@ describe('WatchlistPageComponent', () => {
fixture = TestBed.createComponent(WatchlistPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('renders entries from the mocked API', async () => {
await component.loadEntries();
await fixture.whenStable();
fixture.detectChanges();
});
it('renders entries from the mocked API', () => {
const rows: NodeListOf<HTMLTableRowElement> =
fixture.nativeElement.querySelectorAll('[data-testid="entry-row"]');
expect(rows.length).toBeGreaterThan(0);
expect(component.entryCount()).toBe(rows.length);
});
it('displays the create entry button', () => {
const btn = fixture.nativeElement.querySelector('[data-testid="create-entry-btn"]');
expect(btn).toBeTruthy();
expect(btn.textContent).toContain('New Entry');
});
it('switches to edit view when create button is clicked', () => {
it('opens the create panel from the mounted entries shell', () => {
component.createNew();
fixture.detectChanges();
expect(component.activeTab()).toBe('entries');
expect(component.entryPanelMode()).toBe('create');
expect(component.viewMode()).toBe('edit');
expect(component.selectedEntry()).toBeNull();
expect(
fixture.nativeElement.querySelector('[data-testid="entry-form"]')
).toBeTruthy();
});
it('populates form when editing an entry', async () => {
await component.loadEntries();
it('duplicates an existing rule into a draft', () => {
const sourceEntry = component.entries()[0];
component.duplicateEntry(sourceEntry);
fixture.detectChanges();
const entries = component.entries();
expect(entries.length).toBeGreaterThan(0);
const firstEntry = entries[0];
component.editEntry(firstEntry);
fixture.detectChanges();
expect(component.viewMode()).toBe('edit');
expect(component.selectedEntry()).toBe(firstEntry);
expect(component.entryForm.value.displayName).toBe(firstEntry.displayName);
expect(component.entryPanelMode()).toBe('duplicate');
expect(component.entryForm.controls.displayName.value).toContain('copy');
expect(component.entryForm.controls.issuer.value).toBe(sourceEntry.issuer ?? '');
});
it('validates at least one identity field is required', async () => {
it('creates a new entry and returns to the list workspace', async () => {
component.createNew();
fixture.detectChanges();
component.entryForm.patchValue({
displayName: 'Test Entry',
issuer: '',
subjectAlternativeName: '',
keyId: '',
});
await component.saveEntry();
fixture.detectChanges();
expect(component.messageType()).toBe('error');
expect(component.message()).toContain('identity field');
});
it('creates a new entry with valid data', async () => {
component.createNew();
fixture.detectChanges();
const initialCount = component.entries().length;
component.entryForm.patchValue({
displayName: 'New Test Entry',
issuer: 'https://test.example.com',
displayName: 'Release signer rule',
issuer: 'https://issuer.example',
severity: 'Critical',
enabled: true,
matchMode: 'Exact',
scope: 'Tenant',
severity: 'Warning',
enabled: true,
});
await component.saveEntry();
fixture.detectChanges();
await component.loadEntries();
fixture.detectChanges();
expect(component.entries().length).toBeGreaterThan(initialCount);
expect(component.entryPanelMode()).toBeNull();
expect(component.viewMode()).toBe('list');
expect(component.messageType()).toBe('success');
expect(
component.entries().some((entry) => entry.displayName === 'Release signer rule')
).toBeTrue();
});
it('switches to alerts view when alerts tab is clicked', () => {
component.showAlerts();
fixture.detectChanges();
expect(component.viewMode()).toBe('alerts');
});
it('loads alerts when alerts view is shown', async () => {
it('opens alerts and drill-in detail from the same shell', async () => {
component.showAlerts();
await component.loadAlerts();
fixture.detectChanges();
const rows: NodeListOf<HTMLTableRowElement> =
fixture.nativeElement.querySelectorAll('[data-testid="alert-row"]');
expect(rows.length).toBeGreaterThan(0);
});
it('filters entries by severity', async () => {
await component.loadEntries();
const alert = component.filteredAlerts()[0];
component.openAlertDetail(alert);
fixture.detectChanges();
const totalCount = component.filteredEntries().length;
expect(component.activeTab()).toBe('alerts');
expect(component.selectedAlertId()).toBe(alert.alertId);
expect(
fixture.nativeElement.querySelector('[data-testid="alert-detail"]')
).toBeTruthy();
});
component.severityFilter.set('Critical');
it('binds the tuning form to the selected rule', () => {
const entry = component.entries()[0];
component.openTuningForEntry(entry);
fixture.detectChanges();
const filteredCount = component.filteredEntries().length;
expect(filteredCount).toBeLessThanOrEqual(totalCount);
expect(component.activeTab()).toBe('tuning');
expect(component.selectedEntryId()).toBe(entry.id);
expect(component.tuningForm.controls.suppressDuplicatesMinutes.value).toBe(
entry.suppressDuplicatesMinutes
);
});
it('returns correct severity class', () => {
expect(component.getSeverityClass('Critical')).toBe('severity-critical');
expect(component.getSeverityClass('Warning')).toBe('severity-warning');
expect(component.getSeverityClass('Info')).toBe('severity-info');
expect(component.getSeverityClass('Unknown')).toBe('');
});
it('returns correct match mode label', () => {
expect(component.getMatchModeLabel('Exact')).toBe('Exact match');
expect(component.getMatchModeLabel('Prefix')).toBe('Prefix match');
expect(component.getMatchModeLabel('Glob')).toBe('Glob pattern');
expect(component.getMatchModeLabel('Regex')).toBe('Regular expression');
expect(component.getMatchModeLabel('Other')).toBe('Other');
});
it('formats dates correctly', () => {
const isoDate = '2026-01-29T12:30:00Z';
const formatted = component.formatDate(isoDate);
expect(formatted).toContain('Jan');
expect(formatted).toContain('29');
expect(formatted).toContain('2026');
});
it('shows success message on successful operations', () => {
// Access private method via any cast for testing
(component as any).showSuccess('Test success message');
expect(component.message()).toBe('Test success message');
expect(component.messageType()).toBe('success');
});
it('shows error message on failed operations', () => {
// Access private method via any cast for testing
(component as any).showError('Test error message');
expect(component.message()).toBe('Test error message');
expect(component.messageType()).toBe('error');
});
it('dismisses message when banner dismiss button is clicked', () => {
component.message.set('Test message');
it('changes scope immediately in the shell state', () => {
component.changeScope('global');
fixture.detectChanges();
const dismissBtn = fixture.nativeElement.querySelector('.message-banner .dismiss');
if (dismissBtn) {
dismissBtn.click();
fixture.detectChanges();
expect(component.message()).toBeNull();
}
expect(component.scopeFilter()).toBe('global');
expect(
component.filteredEntries().every((entry) => entry.scope === 'Global')
).toBeTrue();
});
it('tests pattern matching for an existing entry', async () => {
await component.loadEntries();
fixture.detectChanges();
const entries = component.entries();
expect(entries.length).toBeGreaterThan(0);
const firstEntry = entries[0];
component.editEntry(firstEntry);
fixture.detectChanges();
const entry = component.entries()[0];
component.editEntry(entry);
component.testForm.patchValue({
issuer: firstEntry.issuer ?? '',
subjectAlternativeName: firstEntry.subjectAlternativeName ?? '',
issuer: entry.issuer ?? '',
subjectAlternativeName: entry.subjectAlternativeName ?? '',
});
await component.testPattern();
fixture.detectChanges();
expect(component.testResult()).not.toBeNull();
});
it('toggles entry enabled status', async () => {
await component.loadEntries();
fixture.detectChanges();
const entries = component.entries();
expect(entries.length).toBeGreaterThan(0);
const firstEntry = entries[0];
const originalEnabled = firstEntry.enabled;
await component.toggleEnabled(firstEntry);
fixture.detectChanges();
await component.loadEntries();
fixture.detectChanges();
const updatedEntry = component.entries().find(e => e.id === firstEntry.id);
expect(updatedEntry?.enabled).toBe(!originalEnabled);
});
it('returns to list view when cancel is clicked in edit mode', () => {
component.createNew();
fixture.detectChanges();
expect(component.viewMode()).toBe('edit');
component.showList();
fixture.detectChanges();
expect(component.viewMode()).toBe('list');
});
it('provides trackBy functions for performance', () => {
const mockEntry = { id: 'test-123' } as any;
const mockAlert = { alertId: 'alert-456' } as any;
expect(component.trackByEntry(0, mockEntry)).toBe('test-123');
expect(component.trackByAlert(0, mockAlert)).toBe('alert-456');
expect(component.testResult()?.matches).toBeTrue();
});
});

View File

@@ -58,6 +58,17 @@ describe('AppSidebarComponent', () => {
expect(cancelSpy).toHaveBeenCalledWith(1);
});
it('shows Trust & Signing under Setup for setup-capable operators', () => {
setScopes([
StellaOpsScopes.UI_ADMIN,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
]);
const fixture = createComponent();
expect(fixture.nativeElement.textContent).toContain('Trust & Signing');
});
function setScopes(scopes: readonly StellaOpsScope[]): void {
const baseUser = authService.user();
if (!baseUser) {

View File

@@ -859,6 +859,7 @@ export class AppSidebarComponent implements AfterViewInit {
{ id: 'setup-topology', label: 'Topology', route: '/setup/topology/overview', icon: 'globe' },
{ id: 'setup-integrations', label: 'Integrations', route: '/setup/integrations', icon: 'plug' },
{ id: 'setup-iam', label: 'Identity & Access', route: '/setup/identity-access', icon: 'user' },
{ id: 'setup-trust-signing', label: 'Trust & Signing', route: '/setup/trust-signing', icon: 'shield' },
{ id: 'setup-branding', label: 'Tenant & Branding', route: '/setup/tenant-branding', icon: 'paintbrush' },
{ id: 'setup-notifications', label: 'Notifications', route: '/setup/notifications', icon: 'bell' },
],

View File

@@ -0,0 +1,43 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { NOTIFY_API } from '../../app/core/api/notify.client';
import { NotifyPanelComponent } from '../../app/features/notify/notify-panel.component';
import { MockNotifyApiService } from '../../app/testing/mock-notify-api.service';
describe('Notify watchlist handoff', () => {
let fixture: ComponentFixture<NotifyPanelComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NotifyPanelComponent],
providers: [
provideRouter([]),
MockNotifyApiService,
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
],
}).compileComponents();
fixture = TestBed.createComponent(NotifyPanelComponent);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
});
it('links notification operators back to the canonical watchlist shell', () => {
const links = Array.from(
fixture.nativeElement.querySelectorAll('a')
) as HTMLAnchorElement[];
expect(
links.some((link) =>
link.getAttribute('href')?.includes('/setup/trust-signing/watchlist/tuning')
)
).toBeTrue();
expect(
links.some((link) =>
link.getAttribute('href')?.includes('/setup/trust-signing/watchlist/alerts')
)
).toBeTrue();
});
});

View File

@@ -79,7 +79,7 @@ describe('trust-scoring-dashboard-ui behavior', () => {
component = fixture.componentInstance;
});
it('declares trust-admin routes for keys, issuers, certificates, audit, airgap, incidents, and analytics', () => {
it('declares trust-admin routes for keys, issuers, certificates, watchlist, audit, airgap, incidents, and analytics', () => {
const root = trustAdminRoutes.find((route) => route.path === '');
expect(root).toBeDefined();
@@ -89,6 +89,10 @@ describe('trust-scoring-dashboard-ui behavior', () => {
'keys',
'issuers',
'certificates',
'watchlist',
'watchlist/entries',
'watchlist/alerts',
'watchlist/tuning',
'audit',
'airgap',
'incidents',
@@ -120,6 +124,9 @@ describe('trust-scoring-dashboard-ui behavior', () => {
(component as any).setActiveTabFromUrl('/admin/trust/analytics?scope=week');
expect(component.activeTab()).toBe('analytics');
(component as any).setActiveTabFromUrl('/setup/trust-signing/watchlist/alerts?scope=tenant');
expect(component.activeTab()).toBe('watchlist');
(component as any).setActiveTabFromUrl('/admin/trust/not-a-tab');
expect(component.activeTab()).toBe('keys');
});

View File

@@ -1,40 +1,53 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { throwError } from 'rxjs';
import { provideRouter } from '@angular/router';
import { WATCHLIST_API, WatchlistMockClient } from '../../app/core/api/watchlist.client';
import {
WATCHLIST_API,
WatchlistMockClient,
} from '../../app/core/api/watchlist.client';
import { WatchlistPageComponent } from '../../app/features/watchlist/watchlist-page.component';
describe('Identity Watchlist Management UI (watchlist)', () => {
let fixture: ComponentFixture<WatchlistPageComponent>;
let component: WatchlistPageComponent;
let api: WatchlistMockClient;
beforeEach(async () => {
api = new WatchlistMockClient();
await TestBed.configureTestingModule({
imports: [WatchlistPageComponent],
providers: [{ provide: WATCHLIST_API, useValue: api }],
providers: [
provideRouter([]),
{ provide: WATCHLIST_API, useClass: WatchlistMockClient },
],
}).compileComponents();
fixture = TestBed.createComponent(WatchlistPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
});
it('renders list rows from watchlist entries', async () => {
fixture.detectChanges();
await component.loadEntries();
fixture.detectChanges();
it('renders list rows from watchlist entries', () => {
const rows = fixture.nativeElement.querySelectorAll('[data-testid="entry-row"]');
expect(rows.length).toBe(component.entryCount());
expect(rows.length).toBeGreaterThan(0);
expect(component.entryCount()).toBe(rows.length);
});
it('creates a new watchlist entry from the edit form', async () => {
it('moves between entries, alerts, and tuning inside one mounted shell', () => {
component.showAlerts();
fixture.detectChanges();
await component.loadEntries();
expect(component.activeTab()).toBe('alerts');
component.showTuning();
fixture.detectChanges();
expect(component.activeTab()).toBe('tuning');
component.showList();
fixture.detectChanges();
expect(component.activeTab()).toBe('entries');
});
it('creates a new watchlist entry from the mounted entries panel', async () => {
component.createNew();
component.entryForm.patchValue({
displayName: 'Release signer rule',
@@ -46,69 +59,48 @@ describe('Identity Watchlist Management UI (watchlist)', () => {
});
await component.saveEntry();
await component.loadEntries();
fixture.detectChanges();
expect(component.viewMode()).toBe('list');
expect(component.entryPanelMode()).toBeNull();
expect(component.messageType()).toBe('success');
expect(component.entries().some((entry) => entry.displayName === 'Release signer rule')).toBeTrue();
expect(
component.entries().some((entry) => entry.displayName === 'Release signer rule')
).toBeTrue();
});
it('validates that at least one identity field is required', async () => {
fixture.detectChanges();
await component.loadEntries();
component.createNew();
component.entryForm.patchValue({
displayName: 'Invalid rule',
issuer: '',
subjectAlternativeName: '',
keyId: '',
});
await component.saveEntry();
expect(component.messageType()).toBe('error');
expect(component.message()).toContain('identity field');
});
it('tests watchlist pattern matches for an existing entry', async () => {
fixture.detectChanges();
await component.loadEntries();
const entry = component.entries()[0];
component.editEntry(entry);
component.testForm.patchValue({
issuer: entry.issuer ?? '',
subjectAlternativeName: entry.subjectAlternativeName ?? '',
});
await component.testPattern();
fixture.detectChanges();
expect(component.testResult()).not.toBeNull();
expect(component.testResult()?.matches).toBeTrue();
});
it('loads recent alerts in alerts mode', async () => {
fixture.detectChanges();
it('loads recent alerts and opens alert detail drill-in', async () => {
component.showAlerts();
await component.loadAlerts();
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('[data-testid="alert-row"]');
expect(rows.length).toBeGreaterThan(0);
expect(component.alerts().length).toBe(rows.length);
const alert = component.filteredAlerts()[0];
component.openAlertDetail(alert);
fixture.detectChanges();
expect(component.selectedAlertId()).toBe(alert.alertId);
expect(fixture.nativeElement.querySelector('[data-testid="alert-detail"]')).toBeTruthy();
});
it('surfaces API error when entry load fails', async () => {
fixture.detectChanges();
spyOn(api, 'listEntries').and.returnValue(throwError(() => new Error('offline')));
await component.loadEntries();
it('defaults the alerts window selector to 24h when the alerts tab mounts', () => {
component.showAlerts();
fixture.detectChanges();
expect(component.messageType()).toBe('error');
expect(component.message()).toContain('Failed to load watchlist entries');
const windowSelect = fixture.nativeElement.querySelector(
'[data-testid="alerts-window-select"]'
) as HTMLSelectElement | null;
expect(component.alertWindow()).toBe('24h');
expect(windowSelect?.value).toBe('24h');
});
it('selects a tuning target and patches the tuning controls', () => {
const entry = component.entries()[0];
component.openTuningForEntry(entry);
fixture.detectChanges();
expect(component.tuningTarget()?.id).toBe(entry.id);
expect(component.tuningForm.controls.channelOverridesText.value).toBe(
(entry.channelOverrides ?? []).join('\n')
);
});
});

View File

@@ -0,0 +1,334 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
const adminSession: StubAuthSession = {
subjectId: 'e2e-admin-user',
tenant: 'tenant-default',
scopes: [
'admin',
'ui.read',
'ui.admin',
'release:read',
'release:write',
'release:publish',
'scanner:read',
'sbom:read',
'advisory:read',
'vex:read',
'vex:export',
'exception:read',
'exception:approve',
'exceptions:read',
'findings:read',
'vuln:view',
'policy:read',
'policy:author',
'policy:review',
'policy:approve',
'policy:simulate',
'policy:audit',
'orch:read',
'orch:operate',
'health:read',
'notify.viewer',
'signer:read',
'authority:audit.read',
],
};
const mockConfig = {
authority: {
issuer: '/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: '/authority/connect/authorize',
tokenEndpoint: '/authority/connect/token',
logoutEndpoint: '/authority/connect/logout',
redirectUri: 'https://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
scope: 'openid profile email ui.read',
audience: '/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
function isoMinutesAgo(minutes: number): string {
return new Date(Date.now() - minutes * 60 * 1000).toISOString();
}
const watchlistEntries = [
{
id: '11111111-1111-1111-1111-111111111111',
tenantId: 'tenant-default',
displayName: 'GitHub Actions Watcher',
description: 'Track GitHub release signers',
issuer: 'https://token.actions.githubusercontent.com',
subjectAlternativeName: 'repo:org/*',
matchMode: 'Glob',
scope: 'Tenant',
severity: 'Critical',
enabled: true,
suppressDuplicatesMinutes: 60,
channelOverrides: ['slack:security-alerts'],
tags: ['ci', 'github'],
createdAt: '2026-03-01T08:00:00Z',
updatedAt: '2026-03-06T08:00:00Z',
createdBy: 'admin@example.com',
updatedBy: 'admin@example.com',
},
{
id: '22222222-2222-2222-2222-222222222222',
tenantId: 'tenant-default',
displayName: 'Google Cloud IAM',
description: 'Track Google Cloud service account identities',
issuer: 'https://accounts.google.com',
matchMode: 'Prefix',
scope: 'Tenant',
severity: 'Warning',
enabled: true,
suppressDuplicatesMinutes: 120,
tags: ['cloud', 'gcp'],
createdAt: '2026-03-02T08:00:00Z',
updatedAt: '2026-03-05T08:00:00Z',
createdBy: 'admin@example.com',
updatedBy: 'admin@example.com',
},
];
const watchlistAlerts = [
{
alertId: 'alert-001',
watchlistEntryId: '11111111-1111-1111-1111-111111111111',
watchlistEntryName: 'GitHub Actions Watcher',
severity: 'Critical',
matchedIssuer: 'https://token.actions.githubusercontent.com',
matchedSan: 'repo:org/app:ref:refs/heads/main',
rekorUuid: 'abc123def456',
rekorLogIndex: 12345678,
occurredAt: isoMinutesAgo(15),
},
{
alertId: 'alert-002',
watchlistEntryId: '22222222-2222-2222-2222-222222222222',
watchlistEntryName: 'Google Cloud IAM',
severity: 'Warning',
matchedIssuer: 'https://accounts.google.com',
matchedSan: 'service-account@project.iam.gserviceaccount.com',
rekorUuid: 'xyz789abc012',
rekorLogIndex: 12345679,
occurredAt: isoMinutesAgo(120),
},
];
const watchlistEntriesRoute = /\/api\/v1\/watchlist(?:\?.*)?$/;
const watchlistAlertsRoute = /\/api\/v1\/watchlist\/alerts(?:\?.*)?$/;
async function fulfillJson(route: Route, body: unknown): Promise<void> {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
}
async function setupHarness(page: Page): Promise<void> {
await page.addInitScript((session) => {
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, adminSession);
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/.well-known/openid-configuration', (route) =>
fulfillJson(route, {
issuer: 'https://127.0.0.1:4400/authority',
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
})
);
await page.route('**/authority/.well-known/jwks.json', (route) =>
fulfillJson(route, { keys: [] })
);
await page.route('**/console/profile**', (route) =>
fulfillJson(route, {
subjectId: adminSession.subjectId,
username: 'qa-tester',
displayName: 'QA Test User',
tenant: adminSession.tenant,
roles: ['admin'],
scopes: adminSession.scopes,
})
);
await page.route('**/console/token/introspect**', (route) =>
fulfillJson(route, {
active: true,
tenant: adminSession.tenant,
subject: adminSession.subjectId,
scopes: adminSession.scopes,
})
);
await page.route('**/api/v2/context/regions', (route) =>
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }])
);
await page.route('**/api/v2/context/environments**', (route) =>
fulfillJson(route, [
{
environmentId: 'prod',
regionId: 'eu-west',
environmentType: 'prod',
displayName: 'Prod',
sortOrder: 1,
enabled: true,
},
])
);
await page.route('**/api/v2/context/preferences', (route) =>
fulfillJson(route, {
tenantId: adminSession.tenant,
actorId: adminSession.subjectId,
regions: ['eu-west'],
environments: ['prod'],
timeWindow: '24h',
stage: 'all',
updatedAt: '2026-03-07T12:00:00Z',
updatedBy: adminSession.subjectId,
})
);
await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, []));
await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, []));
await page.route('**/api/v1/trust/dashboard**', (route) =>
fulfillJson(route, {
keys: { total: 12, active: 9, expiringSoon: 2, expired: 1, revoked: 0, pendingRotation: 1 },
issuers: { total: 8, fullTrust: 3, partialTrust: 3, minimalTrust: 1, untrusted: 1, blocked: 0, averageTrustScore: 86.4 },
certificates: { total: 5, valid: 4, expiringSoon: 1, expired: 0, revoked: 0, invalidChains: 0 },
recentEvents: [],
expiryAlerts: [],
})
);
await page.route(watchlistEntriesRoute, (route) =>
fulfillJson(route, { items: watchlistEntries, totalCount: watchlistEntries.length })
);
await page.route(watchlistAlertsRoute, (route) =>
fulfillJson(route, { items: watchlistAlerts, totalCount: watchlistAlerts.length })
);
await page.route('**/api/v1/notify/channels**', (route) =>
fulfillJson(route, [
{
schemaVersion: '1.0',
channelId: 'channel-1',
tenantId: 'tenant-default',
name: 'Security Slack',
displayName: 'Security Slack',
type: 'Slack',
enabled: true,
config: { secretRef: 'notify/slack', target: '#security', endpoint: 'https://hooks.slack.com', properties: {} },
labels: {},
metadata: {},
createdBy: 'system',
createdAt: '2026-03-01T08:00:00Z',
updatedBy: 'system',
updatedAt: '2026-03-01T08:00:00Z',
},
])
);
await page.route('**/api/v1/notify/rules**', (route) =>
fulfillJson(route, [
{
schemaVersion: '1.0',
ruleId: 'rule-1',
tenantId: 'tenant-default',
name: 'Critical alerts',
enabled: true,
match: { eventKinds: ['watchlist.alert'], labels: ['critical'], minSeverity: 'critical' },
actions: [{ actionId: 'action-1', channel: 'channel-1', digest: 'instant', enabled: true, metadata: {} }],
labels: {},
metadata: {},
createdBy: 'system',
createdAt: '2026-03-01T08:00:00Z',
updatedBy: 'system',
updatedAt: '2026-03-01T08:00:00Z',
},
])
);
await page.route('**/api/v1/notify/deliveries**', (route) =>
fulfillJson(route, {
items: [
{
deliveryId: 'delivery-1',
tenantId: 'tenant-default',
channelId: 'channel-1',
ruleId: 'rule-1',
kind: 'watchlist.alert',
status: 'Sent',
rendered: { target: '#security', subject: 'Critical watchlist alert' },
createdAt: '2026-03-07T14:32:00Z',
},
],
totalCount: 1,
})
);
await page.route('**/api/v1/notify/channels/*/health**', (route) =>
fulfillJson(route, {
status: 'Healthy',
message: 'Channel operating normally',
checkedAt: '2026-03-07T16:15:00Z',
traceId: 'trace-1',
})
);
}
test.beforeEach(async ({ page }) => {
await setupHarness(page);
});
test('watchlist shell supports entries, alerts, and tuning in one routed page', async ({ page }) => {
await page.goto('/setup/trust-signing/watchlist/entries', { waitUntil: 'networkidle' });
await expect(page.getByTestId('watchlist-page')).toBeVisible();
await expect(page.getByRole('button', { name: 'Entries' })).toHaveClass(/active/);
await expect(page.getByTestId('create-entry-btn')).toBeVisible();
await page.getByRole('button', { name: 'Alerts' }).click();
await expect(page).toHaveURL(/\/setup\/trust-signing\/watchlist\/alerts/);
await expect(page.getByTestId('alerts-window-select')).toHaveValue('24h');
await page.getByRole('button', { name: 'View' }).first().click();
await expect(page.getByTestId('alert-detail')).toBeVisible();
await page.getByRole('button', { name: 'Jump to rule' }).click();
await expect(page).toHaveURL(/\/setup\/trust-signing\/watchlist\/entries/);
await expect(page.getByTestId('entry-form')).toBeVisible();
await page.getByRole('button', { name: 'Tuning' }).click();
await expect(page).toHaveURL(/\/setup\/trust-signing\/watchlist\/tuning/);
await expect(page.getByTestId('tuning-form')).toBeVisible();
});
test('mission alerts deep-link into the canonical watchlist alerts flow', async ({ page }) => {
await page.goto('/mission-control/alerts', { waitUntil: 'networkidle' });
await page.getByRole('link', { name: 'Identity watchlist alert requires signer review' }).click();
await expect(page).toHaveURL(/\/setup\/trust-signing\/watchlist\/alerts/);
await expect(page.getByTestId('alert-detail')).toBeVisible();
});
test('notifications hand off to watchlist tuning without spawning a second shell', async ({ page }) => {
await page.goto('/ops/operations/notifications', { waitUntil: 'networkidle' });
await expect(page.getByText('Watchlist handoff')).toBeVisible();
await page.getByRole('link', { name: 'Open watchlist tuning' }).click();
await expect(page).toHaveURL(/\/setup\/trust-signing\/watchlist\/tuning/);
await expect(page.getByTestId('tuning-form')).toBeVisible();
});