feat(ui): ship trust-owned identity watchlist shell
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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 & 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)">×</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">
|
||||
×
|
||||
</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
334
src/Web/StellaOps.Web/tests/e2e/watchlist-shell.spec.ts
Normal file
334
src/Web/StellaOps.Web/tests/e2e/watchlist-shell.spec.ts
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user