Fix watchlist draft hydration and update contracts
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# Sprint 20260311-002 - Watchlist Draft State Preservation
|
||||
|
||||
## Topic & Scope
|
||||
- Repair the live `Trust & Signing > Identity Watchlist` create and duplicate flows so route/context hydration can no longer wipe in-progress operator input.
|
||||
- Prove the fix with focused Angular regression coverage and a real Playwright action sweep that exercises create, edit, pattern test, tuning, duplicate, and delete.
|
||||
- Keep this iteration scoped to the watchlist shell, its live QA harness, and the supporting docs update.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Allowed coordination edits: `docs/implplan/SPRINT_20260311_002_FE_watchlist_draft_state_preservation.md`, `docs/modules/ui/restoration-topics/watchlist.md`.
|
||||
- Expected evidence: focused Angular feature spec coverage, rebuilt web bundle synced into the live compose frontdoor, and a live Playwright sweep artifact for the watchlist page actions.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the authenticated live frontdoor harness already in `src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs`.
|
||||
- Safe parallelism: stay inside `src/Web/StellaOps.Web/**` plus the explicitly allowed docs files; do not take ownership of backend watchlist services or unrelated trust pages in parallel.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `AGENTS.md`
|
||||
- `docs/qa/feature-checks/FLOW.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/ui/restoration-topics/watchlist.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-WATCHLIST-002-001 - Preserve unsaved drafts through route hydration
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Product Manager, Architect, Developer
|
||||
Task description:
|
||||
- The live watchlist shell replays route state after entering the create or duplicate draft route. That replay resets the reactive form, silently erasing user input before submit can reach the API.
|
||||
- Keep route state as the source of truth for which draft is open, but preserve in-progress draft values when hydration replays the same create or duplicate target.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Reapplying the same `entryId=new` route no longer wipes a dirty create draft.
|
||||
- [x] Reapplying the same duplicate draft route no longer reseeds over operator edits.
|
||||
- [x] Legitimate transitions between create, duplicate, edit, alerts, and tuning still rehydrate the correct state.
|
||||
|
||||
### FE-WATCHLIST-002-002 - Add regression coverage for draft preservation
|
||||
Status: DONE
|
||||
Dependency: FE-WATCHLIST-002-001
|
||||
Owners: Developer, QA
|
||||
Task description:
|
||||
- The prior unit coverage only called `saveEntry()` directly and never exercised the route-hydration failure mode.
|
||||
- Add focused component specs that prove dirty create and duplicate drafts survive repeated route-state application for the same target.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Focused Angular coverage asserts dirty create drafts survive repeated route hydration.
|
||||
- [x] Focused Angular coverage asserts dirty duplicate drafts survive repeated route hydration.
|
||||
|
||||
### FE-WATCHLIST-002-003 - Rebuild and prove live watchlist actions with Playwright
|
||||
Status: DONE
|
||||
Dependency: FE-WATCHLIST-002-002
|
||||
Owners: QA
|
||||
Task description:
|
||||
- Rebuild the web bundle, sync it into the live frontdoor static volume, and run a real authenticated Playwright sweep that covers the watchlist action family end to end.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Focused Angular feature coverage passes for `watchlist-page.component.spec.ts`.
|
||||
- [x] `npm run build` passes and the rebuilt bundle is synced into `compose_console-dist`.
|
||||
- [x] A live Playwright sweep artifact records passing checks for create, edit, pattern test, tuning, duplicate, alerts tab, and delete flows on `/setup/trust-signing/watchlist`.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-11 | Sprint created after live Playwright proved the watchlist create draft route was resetting operator input before submit, leaving the page stuck on `entryId=new` with no API call. | Developer |
|
||||
| 2026-03-11 | Preserved dirty create/duplicate drafts through repeated route hydration, stopped refreshes from wiping success banners, normalized watchlist test responses in the HTTP client, and merged full watchlist resources for tuning/toggle updates. Focused Angular coverage passed (`14/14` across `watchlist-page.component.spec.ts` and `watchlist.client.spec.ts`), `npm run build` passed, the rebuilt bundle was synced into `compose_console-dist`, and `src/Web/StellaOps.Web/output/playwright/live-watchlist-action-sweep.json` recorded clean live create/edit/test/tuning/duplicate/delete coverage with zero runtime issues. | QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: route/context hydration is allowed to select the active draft, but it must not erase user-entered values when the route target itself has not changed.
|
||||
- Decision: this is treated as a user-facing workflow regression, not a QA-harness issue. The fix belongs in the watchlist shell state model, not in Playwright timing workarounds.
|
||||
- Decision: the watchlist browser client now normalizes the real backend `matchedFields` payload into the array shape the UI renders. That keeps the browser resilient to the existing backend enum-string contract without changing service behavior mid-iteration.
|
||||
- Decision: watchlist `PUT` remains a full-resource update contract. The web shell now sends merged full payloads for tuning/toggle flows instead of assuming backend partial-update semantics.
|
||||
- Risk: the watchlist page still relies on frontdoor query hydration, so adjacent trust pages may carry similar draft-reset risks and should be covered in later action sweeps.
|
||||
|
||||
## Next Checkpoints
|
||||
- 2026-03-11: land the watchlist state-model fix and focused frontend coverage.
|
||||
- 2026-03-11: rebuild and sync the web bundle into the live compose frontdoor.
|
||||
- 2026-03-11: rerun authenticated Playwright against `/setup/trust-signing/watchlist` and commit the iteration locally.
|
||||
@@ -55,6 +55,7 @@ Merge these current behaviors into the new shell:
|
||||
### Create / edit entry
|
||||
- Use a drawer or split detail panel inside `Entries`
|
||||
- Do not create a separate route unless audit history requires a permalink
|
||||
- Unsaved create and duplicate drafts must survive route/context hydration so tenant, region, or scope query updates do not wipe operator input mid-edit.
|
||||
|
||||
### Test pattern
|
||||
- Keep it in the create/edit panel
|
||||
|
||||
370
src/Web/StellaOps.Web/scripts/live-watchlist-action-sweep.mjs
Normal file
370
src/Web/StellaOps.Web/scripts/live-watchlist-action-sweep.mjs
Normal file
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.resolve(__dirname, '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const outputPath = path.join(outputDir, 'live-watchlist-action-sweep.json');
|
||||
const authStatePath = path.join(outputDir, 'live-watchlist-action-sweep.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-watchlist-action-sweep.auth.json');
|
||||
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
||||
|
||||
function uniqueSuffix() {
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function settle(page, timeout = 1_500) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
|
||||
await page.waitForTimeout(timeout);
|
||||
}
|
||||
|
||||
async function headingText(page) {
|
||||
const headings = page.locator('h1, h2, [data-testid="page-title"], .page-title');
|
||||
const count = await headings.count();
|
||||
for (let index = 0; index < Math.min(count, 4); index += 1) {
|
||||
const text = (await headings.nth(index).innerText().catch(() => '')).trim();
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function captureSnapshot(page, label) {
|
||||
return {
|
||||
label,
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
heading: await headingText(page),
|
||||
message: (await page.locator('.message-banner').textContent().catch(() => '')).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function navigate(page, route) {
|
||||
const separator = route.includes('?') ? '&' : '?';
|
||||
const url = `https://stella-ops.local${route}${separator}${scopeQuery}`;
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
await settle(page);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function findNav(page, label) {
|
||||
const candidates = [
|
||||
page.getByRole('tab', { name: label }).first(),
|
||||
page.getByRole('button', { name: label }).first(),
|
||||
page.getByRole('link', { name: label }).first(),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if ((await candidate.count()) > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openEntriesTab(page) {
|
||||
const locator = await findNav(page, 'Entries');
|
||||
if (locator) {
|
||||
await locator.click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSubmitEnabled(page, label) {
|
||||
await page.waitForFunction(
|
||||
(buttonLabel) => {
|
||||
const button = Array.from(document.querySelectorAll('button'))
|
||||
.find((candidate) => (candidate.textContent || '').replace(/\s+/g, ' ').trim() === buttonLabel);
|
||||
return !!button && !button.disabled;
|
||||
},
|
||||
label,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function rowLocator(page, text) {
|
||||
return page.locator('tr[data-testid="entry-row"]').filter({ hasText: text }).first();
|
||||
}
|
||||
|
||||
async function waitForRow(page, text) {
|
||||
const row = await rowLocator(page, text);
|
||||
await row.waitFor({ state: 'visible', timeout: 20_000 });
|
||||
return row;
|
||||
}
|
||||
|
||||
async function fillEntryForm(page, values) {
|
||||
const form = page.locator('form[data-testid="entry-form"]');
|
||||
if (values.displayName !== undefined) {
|
||||
await form.locator('input[formcontrolname="displayName"]').fill(values.displayName);
|
||||
}
|
||||
if (values.issuer !== undefined) {
|
||||
await form.locator('input[formcontrolname="issuer"]').fill(values.issuer);
|
||||
}
|
||||
if (values.keyId !== undefined) {
|
||||
await form.locator('input[formcontrolname="keyId"]').fill(values.keyId);
|
||||
}
|
||||
}
|
||||
|
||||
async function clickEntryAction(page, rowText, actionLabel) {
|
||||
const row = await waitForRow(page, rowText);
|
||||
await row.getByRole('button', { name: actionLabel }).click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function waitForMessage(page, text) {
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const banner = document.querySelector('.message-banner');
|
||||
return !!banner && (banner.textContent || '').includes(expected);
|
||||
},
|
||||
text,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
let currentAction = 'authenticate-frontdoor';
|
||||
let browser;
|
||||
let context;
|
||||
const runtime = {
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
responseErrors: [],
|
||||
requestFailures: [],
|
||||
};
|
||||
|
||||
const suffix = uniqueSuffix();
|
||||
const createdName = `QA Watchlist ${suffix}`;
|
||||
const updatedName = `${createdName} Updated`;
|
||||
const duplicateName = `${createdName} Duplicate`;
|
||||
const issuer = `https://${suffix}.example.test`;
|
||||
const keyId = `kid-${suffix}`;
|
||||
const results = [];
|
||||
let page;
|
||||
|
||||
try {
|
||||
const authReport = await authenticateFrontdoor({
|
||||
statePath: authStatePath,
|
||||
reportPath: authReportPath,
|
||||
headless: true,
|
||||
});
|
||||
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath });
|
||||
page = await context.newPage();
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => {
|
||||
runtime.pageErrors.push({ page: page.url(), message: error.message });
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
const url = request.url();
|
||||
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.requestFailures.push({
|
||||
page: page.url(),
|
||||
method: request.method(),
|
||||
url,
|
||||
error: request.failure()?.errorText ?? 'unknown',
|
||||
});
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status() >= 400) {
|
||||
runtime.responseErrors.push({
|
||||
page: page.url(),
|
||||
method: response.request().method(),
|
||||
status: response.status(),
|
||||
url,
|
||||
});
|
||||
}
|
||||
});
|
||||
page.on('dialog', (dialog) => dialog.accept().catch(() => undefined));
|
||||
|
||||
currentAction = 'route:/setup/trust-signing/watchlist/entries';
|
||||
await navigate(page, '/setup/trust-signing/watchlist/entries');
|
||||
results.push({
|
||||
action: 'route:/setup/trust-signing/watchlist/entries',
|
||||
ok: (await headingText(page)).length > 0 && (await page.getByTestId('watchlist-page').count()) > 0,
|
||||
snapshot: await captureSnapshot(page, 'watchlist-entries'),
|
||||
});
|
||||
|
||||
currentAction = 'create-entry';
|
||||
await page.getByTestId('create-entry-btn').click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
await fillEntryForm(page, { displayName: createdName, issuer, keyId });
|
||||
await waitForSubmitEnabled(page, 'Create rule');
|
||||
await page.getByRole('button', { name: 'Create rule' }).click({ timeout: 10_000 });
|
||||
await waitForMessage(page, 'Watchlist entry created.');
|
||||
await waitForRow(page, createdName);
|
||||
results.push({
|
||||
action: 'create-entry',
|
||||
ok: true,
|
||||
snapshot: await captureSnapshot(page, 'after-create'),
|
||||
});
|
||||
|
||||
currentAction = 'edit-entry';
|
||||
await clickEntryAction(page, createdName, 'Edit');
|
||||
await fillEntryForm(page, { displayName: updatedName, issuer, keyId });
|
||||
await waitForSubmitEnabled(page, 'Save changes');
|
||||
await page.getByRole('button', { name: 'Save changes' }).click({ timeout: 10_000 });
|
||||
await waitForMessage(page, 'Watchlist entry updated.');
|
||||
await waitForRow(page, updatedName);
|
||||
results.push({
|
||||
action: 'edit-entry',
|
||||
ok: true,
|
||||
snapshot: await captureSnapshot(page, 'after-edit'),
|
||||
});
|
||||
|
||||
currentAction = 'test-pattern';
|
||||
await clickEntryAction(page, updatedName, 'Edit');
|
||||
const testSection = page.locator('.test-section');
|
||||
await testSection.locator('input[formcontrolname="issuer"]').fill(issuer);
|
||||
await testSection.locator('input[formcontrolname="keyId"]').fill(keyId);
|
||||
await page.getByRole('button', { name: 'Run test' }).click({ timeout: 10_000 });
|
||||
await page.getByTestId('test-result').waitFor({ state: 'visible', timeout: 20_000 });
|
||||
const testText = (await page.getByTestId('test-result').textContent().catch(() => '')).trim();
|
||||
results.push({
|
||||
action: 'test-pattern',
|
||||
ok: testText.includes('Match'),
|
||||
snapshot: await captureSnapshot(page, 'after-test-pattern'),
|
||||
detail: testText,
|
||||
});
|
||||
await page.getByRole('button', { name: 'Close' }).click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
|
||||
currentAction = 'duplicate-entry';
|
||||
await clickEntryAction(page, updatedName, 'Duplicate');
|
||||
await fillEntryForm(page, { displayName: duplicateName, issuer, keyId });
|
||||
await waitForSubmitEnabled(page, 'Create duplicate');
|
||||
await page.getByRole('button', { name: 'Create duplicate' }).click({ timeout: 10_000 });
|
||||
await waitForMessage(page, 'Watchlist entry duplicated.');
|
||||
await waitForRow(page, duplicateName);
|
||||
results.push({
|
||||
action: 'duplicate-entry',
|
||||
ok: true,
|
||||
snapshot: await captureSnapshot(page, 'after-duplicate'),
|
||||
});
|
||||
|
||||
currentAction = 'save-tuning';
|
||||
await clickEntryAction(page, updatedName, 'Tune');
|
||||
const tuningForm = page.locator('form[data-testid="tuning-form"]');
|
||||
await tuningForm.locator('input[formcontrolname="suppressDuplicatesMinutes"]').fill('15');
|
||||
await tuningForm.locator('textarea[formcontrolname="channelOverridesText"]').fill('slack:security');
|
||||
await waitForSubmitEnabled(page, 'Save tuning');
|
||||
await page.getByRole('button', { name: 'Save tuning' }).click({ timeout: 10_000 });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const banner = document.querySelector('.message-banner');
|
||||
return !!banner && (banner.textContent || '').includes('Saved tuning for');
|
||||
},
|
||||
null,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
results.push({
|
||||
action: 'save-tuning',
|
||||
ok: true,
|
||||
snapshot: await captureSnapshot(page, 'after-save-tuning'),
|
||||
});
|
||||
|
||||
currentAction = 'alerts-tab';
|
||||
const alertsTab = await findNav(page, 'Alerts');
|
||||
if (alertsTab) {
|
||||
await alertsTab.click({ timeout: 10_000 });
|
||||
await settle(page);
|
||||
const alertRows = await page.locator('tr[data-testid="alert-row"]').count();
|
||||
const emptyState = (await page.locator('.empty-state').first().textContent().catch(() => '')).trim();
|
||||
results.push({
|
||||
action: 'alerts-tab',
|
||||
ok: alertRows > 0 || emptyState.includes('No alerts match the current scope'),
|
||||
snapshot: await captureSnapshot(page, 'alerts-tab'),
|
||||
detail: alertRows > 0 ? `${alertRows} alert rows` : emptyState,
|
||||
});
|
||||
}
|
||||
|
||||
currentAction = 'delete-duplicate';
|
||||
await openEntriesTab(page);
|
||||
await clickEntryAction(page, duplicateName, 'Delete');
|
||||
await waitForMessage(page, 'Watchlist entry deleted.');
|
||||
await page.waitForFunction(
|
||||
(name) => !Array.from(document.querySelectorAll('tr[data-testid="entry-row"]'))
|
||||
.some((row) => (row.textContent || '').includes(name)),
|
||||
duplicateName,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
results.push({
|
||||
action: 'delete-duplicate',
|
||||
ok: true,
|
||||
snapshot: await captureSnapshot(page, 'after-delete-duplicate'),
|
||||
});
|
||||
|
||||
currentAction = 'delete-original';
|
||||
await clickEntryAction(page, updatedName, 'Delete');
|
||||
await waitForMessage(page, 'Watchlist entry deleted.');
|
||||
await page.waitForFunction(
|
||||
(name) => !Array.from(document.querySelectorAll('tr[data-testid="entry-row"]'))
|
||||
.some((row) => (row.textContent || '').includes(name)),
|
||||
updatedName,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
results.push({
|
||||
action: 'delete-original',
|
||||
ok: true,
|
||||
snapshot: await captureSnapshot(page, 'after-delete-original'),
|
||||
});
|
||||
|
||||
const summary = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
results,
|
||||
runtime,
|
||||
};
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
||||
} catch (error) {
|
||||
const summary = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
failedAction: currentAction,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
results,
|
||||
runtime,
|
||||
finalUrl: page?.url() ?? null,
|
||||
};
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
process.stderr.write(`[live-watchlist-action-sweep] ${summary.error}\n`);
|
||||
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await context?.close().catch(() => undefined);
|
||||
await browser?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,91 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { WatchlistHttpClient } from './watchlist.client';
|
||||
|
||||
describe('WatchlistHttpClient', () => {
|
||||
let client: WatchlistHttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
const entry = {
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
tenantId: 'demo-prod',
|
||||
displayName: 'Release signer',
|
||||
issuer: 'https://issuer.example',
|
||||
matchMode: 'Exact' as const,
|
||||
scope: 'Tenant' as const,
|
||||
severity: 'Warning' as const,
|
||||
enabled: true,
|
||||
suppressDuplicatesMinutes: 60,
|
||||
createdAt: '2026-03-11T08:00:00.000Z',
|
||||
updatedAt: '2026-03-11T08:00:00.000Z',
|
||||
createdBy: 'admin',
|
||||
updatedBy: 'admin',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WatchlistHttpClient,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
|
||||
client = TestBed.inject(WatchlistHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('normalizes comma-delimited matched fields from the HTTP contract', () => {
|
||||
let actual: unknown;
|
||||
|
||||
client.testEntry(entry.id, { issuer: entry.issuer }).subscribe((response) => {
|
||||
actual = response;
|
||||
});
|
||||
|
||||
const request = httpMock.expectOne(`/api/v1/watchlist/${entry.id}/test`);
|
||||
expect(request.request.method).toBe('POST');
|
||||
request.flush({
|
||||
entry,
|
||||
matchScore: 150,
|
||||
matchedFields: 'Issuer, KeyId',
|
||||
matches: true,
|
||||
});
|
||||
|
||||
expect(actual).toEqual({
|
||||
entry,
|
||||
matchScore: 150,
|
||||
matchedFields: ['Issuer', 'KeyId'],
|
||||
matches: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes numeric matched field flags from the HTTP contract', () => {
|
||||
let actual: unknown;
|
||||
|
||||
client.testEntry(entry.id, { issuer: entry.issuer }).subscribe((response) => {
|
||||
actual = response;
|
||||
});
|
||||
|
||||
const request = httpMock.expectOne(`/api/v1/watchlist/${entry.id}/test`);
|
||||
request.flush({
|
||||
entry,
|
||||
matchScore: 200,
|
||||
matchedFields: 5,
|
||||
matches: true,
|
||||
});
|
||||
|
||||
expect(actual).toEqual({
|
||||
entry,
|
||||
matchScore: 200,
|
||||
matchedFields: ['Issuer', 'KeyId'],
|
||||
matches: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,16 @@
|
||||
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
Observable,
|
||||
delay,
|
||||
map,
|
||||
of,
|
||||
} from 'rxjs';
|
||||
|
||||
import {
|
||||
IdentityAlert,
|
||||
type MatchedField,
|
||||
WatchedIdentity,
|
||||
WatchlistAlertsQueryOptions,
|
||||
WatchlistAlertsResponse,
|
||||
@@ -48,7 +54,7 @@ export interface WatchlistApi {
|
||||
/**
|
||||
* Update an existing watchlist entry.
|
||||
*/
|
||||
updateEntry(id: string, request: Partial<WatchlistEntryRequest>): Observable<WatchedIdentity>;
|
||||
updateEntry(id: string, request: WatchlistEntryRequest): Observable<WatchedIdentity>;
|
||||
|
||||
/**
|
||||
* Delete a watchlist entry.
|
||||
@@ -96,7 +102,7 @@ export class WatchlistHttpClient implements WatchlistApi {
|
||||
return this.http.post<WatchedIdentity>(this.baseUrl, request);
|
||||
}
|
||||
|
||||
updateEntry(id: string, request: Partial<WatchlistEntryRequest>): Observable<WatchedIdentity> {
|
||||
updateEntry(id: string, request: WatchlistEntryRequest): Observable<WatchedIdentity> {
|
||||
return this.http.put<WatchedIdentity>(`${this.baseUrl}/${id}`, request);
|
||||
}
|
||||
|
||||
@@ -105,7 +111,9 @@ export class WatchlistHttpClient implements WatchlistApi {
|
||||
}
|
||||
|
||||
testEntry(id: string, request: WatchlistTestRequest): Observable<WatchlistTestResponse> {
|
||||
return this.http.post<WatchlistTestResponse>(`${this.baseUrl}/${id}/test`, request);
|
||||
return this.http
|
||||
.post<RawWatchlistTestResponse>(`${this.baseUrl}/${id}/test`, request)
|
||||
.pipe(map((response) => normalizeWatchlistTestResponse(response)));
|
||||
}
|
||||
|
||||
listAlerts(options?: WatchlistAlertsQueryOptions): Observable<WatchlistAlertsResponse> {
|
||||
@@ -264,7 +272,7 @@ export class WatchlistMockClient implements WatchlistApi {
|
||||
return of(newEntry).pipe(delay(300));
|
||||
}
|
||||
|
||||
updateEntry(id: string, request: Partial<WatchlistEntryRequest>): Observable<WatchedIdentity> {
|
||||
updateEntry(id: string, request: WatchlistEntryRequest): Observable<WatchedIdentity> {
|
||||
const index = this.entries.findIndex((e) => e.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error(`Watchlist entry not found: ${id}`);
|
||||
@@ -374,3 +382,51 @@ export class WatchlistMockClient implements WatchlistApi {
|
||||
return new RegExp(regexPattern, 'i').test(input);
|
||||
}
|
||||
}
|
||||
|
||||
type RawWatchlistTestResponse = Omit<WatchlistTestResponse, 'matchedFields'> & {
|
||||
matchedFields?: MatchedField[] | string | number | null;
|
||||
};
|
||||
|
||||
const MATCHED_FIELD_FLAGS: ReadonlyArray<{ bit: number; field: MatchedField }> = [
|
||||
{ bit: 1, field: 'Issuer' },
|
||||
{ bit: 2, field: 'SubjectAlternativeName' },
|
||||
{ bit: 4, field: 'KeyId' },
|
||||
];
|
||||
|
||||
function normalizeWatchlistTestResponse(
|
||||
response: RawWatchlistTestResponse
|
||||
): WatchlistTestResponse {
|
||||
return {
|
||||
entry: response.entry,
|
||||
matchScore: response.matchScore,
|
||||
matchedFields: normalizeMatchedFields(response.matchedFields),
|
||||
matches: response.matches,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMatchedFields(
|
||||
rawMatchedFields: RawWatchlistTestResponse['matchedFields']
|
||||
): MatchedField[] {
|
||||
if (Array.isArray(rawMatchedFields)) {
|
||||
return rawMatchedFields.filter(isMatchedField);
|
||||
}
|
||||
|
||||
if (typeof rawMatchedFields === 'number') {
|
||||
return MATCHED_FIELD_FLAGS
|
||||
.filter(({ bit }) => (rawMatchedFields & bit) === bit)
|
||||
.map(({ field }) => field);
|
||||
}
|
||||
|
||||
if (typeof rawMatchedFields === 'string') {
|
||||
return rawMatchedFields
|
||||
.split(',')
|
||||
.map((field) => field.trim())
|
||||
.filter(isMatchedField);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function isMatchedField(value: unknown): value is MatchedField {
|
||||
return value === 'Issuer' || value === 'SubjectAlternativeName' || value === 'KeyId';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import {
|
||||
convertToParamMap,
|
||||
provideRouter,
|
||||
} from '@angular/router';
|
||||
|
||||
import {
|
||||
WATCHLIST_API,
|
||||
@@ -76,6 +79,7 @@ describe('WatchlistPageComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.entryPanelMode()).toBeNull();
|
||||
expect(component.message()).toBe('Watchlist entry created.');
|
||||
expect(component.viewMode()).toBe('list');
|
||||
expect(component.messageType()).toBe('success');
|
||||
expect(
|
||||
@@ -83,6 +87,59 @@ describe('WatchlistPageComponent', () => {
|
||||
).toBeTrue();
|
||||
});
|
||||
|
||||
it('preserves a dirty create draft when route hydration replays the same new-entry target', () => {
|
||||
component.createNew();
|
||||
component.entryForm.patchValue({
|
||||
displayName: 'Draft watchlist rule',
|
||||
issuer: 'https://issuer.example',
|
||||
keyId: 'draft-key',
|
||||
});
|
||||
component.entryForm.markAsDirty();
|
||||
|
||||
component['applyRouteState'](
|
||||
['setup', 'trust-signing', 'watchlist', 'entries'],
|
||||
convertToParamMap({
|
||||
entryId: 'new',
|
||||
scope: 'tenant',
|
||||
tab: 'entries',
|
||||
tenant: 'demo-prod',
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.entryPanelMode()).toBe('create');
|
||||
expect(component.entryForm.controls.displayName.value).toBe('Draft watchlist rule');
|
||||
expect(component.entryForm.controls.issuer.value).toBe('https://issuer.example');
|
||||
expect(component.entryForm.controls.keyId.value).toBe('draft-key');
|
||||
});
|
||||
|
||||
it('preserves a dirty duplicate draft when route hydration replays the same duplicate target', () => {
|
||||
const sourceEntry = component.entries()[0];
|
||||
|
||||
component.duplicateEntry(sourceEntry);
|
||||
component.entryForm.patchValue({
|
||||
displayName: 'Custom duplicate draft',
|
||||
issuer: 'https://custom.example',
|
||||
});
|
||||
component.entryForm.markAsDirty();
|
||||
|
||||
component['applyRouteState'](
|
||||
['setup', 'trust-signing', 'watchlist', 'entries'],
|
||||
convertToParamMap({
|
||||
duplicateOf: sourceEntry.id,
|
||||
entryId: 'new',
|
||||
scope: 'tenant',
|
||||
tab: 'entries',
|
||||
tenant: 'demo-prod',
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.entryPanelMode()).toBe('duplicate');
|
||||
expect(component.entryForm.controls.displayName.value).toBe('Custom duplicate draft');
|
||||
expect(component.entryForm.controls.issuer.value).toBe('https://custom.example');
|
||||
});
|
||||
|
||||
it('opens alerts and drill-in detail from the same shell', async () => {
|
||||
component.showAlerts();
|
||||
await component.loadAlerts();
|
||||
@@ -112,6 +169,22 @@ describe('WatchlistPageComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('merges a full watchlist entry for partial tuning updates', () => {
|
||||
const entry = component.entries()[0];
|
||||
const request = component['buildUpdateRequest'](entry, {
|
||||
channelOverrides: undefined,
|
||||
severity: 'Info',
|
||||
suppressDuplicatesMinutes: 15,
|
||||
});
|
||||
|
||||
expect(request.displayName).toBe(entry.displayName);
|
||||
expect(request.issuer).toBe(entry.issuer);
|
||||
expect(request.matchMode).toBe(entry.matchMode);
|
||||
expect(request.scope).toBe(entry.scope);
|
||||
expect(request.suppressDuplicatesMinutes).toBe(15);
|
||||
expect(request.channelOverrides).toBeUndefined();
|
||||
});
|
||||
|
||||
it('changes scope immediately in the shell state', () => {
|
||||
component.changeScope('global');
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
IdentityAlert,
|
||||
IdentityAlertSeverity,
|
||||
WatchedIdentity,
|
||||
WatchlistEntryRequest,
|
||||
WatchlistMatchMode,
|
||||
WatchlistScope,
|
||||
} from '../../core/api/watchlist.models';
|
||||
@@ -425,7 +426,6 @@ export class WatchlistPageComponent implements OnInit {
|
||||
|
||||
async loadEntries(): Promise<void> {
|
||||
this.entriesLoading.set(true);
|
||||
this.message.set(null);
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.api.listEntries({
|
||||
@@ -443,7 +443,6 @@ export class WatchlistPageComponent implements OnInit {
|
||||
|
||||
async loadAlerts(): Promise<void> {
|
||||
this.alertsLoading.set(true);
|
||||
this.message.set(null);
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.listAlerts({ limit: 50 }));
|
||||
this.alerts.set(sortAlerts(response.items));
|
||||
@@ -604,12 +603,12 @@ export class WatchlistPageComponent implements OnInit {
|
||||
try {
|
||||
const raw = this.tuningForm.getRawValue();
|
||||
await firstValueFrom(
|
||||
this.api.updateEntry(entry.id, {
|
||||
this.api.updateEntry(entry.id, this.buildUpdateRequest(entry, {
|
||||
channelOverrides: parseLines(raw.channelOverridesText),
|
||||
enabled: raw.enabled,
|
||||
severity: raw.severity,
|
||||
suppressDuplicatesMinutes: raw.suppressDuplicatesMinutes,
|
||||
})
|
||||
}))
|
||||
);
|
||||
|
||||
this.showSuccess(`Saved tuning for "${entry.displayName}".`);
|
||||
@@ -647,7 +646,7 @@ export class WatchlistPageComponent implements OnInit {
|
||||
this.saving.set(true);
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.api.updateEntry(entry.id, { enabled: !entry.enabled })
|
||||
this.api.updateEntry(entry.id, this.buildUpdateRequest(entry, { enabled: !entry.enabled }))
|
||||
);
|
||||
await this.loadEntries();
|
||||
this.showSuccess(
|
||||
@@ -960,15 +959,23 @@ export class WatchlistPageComponent implements OnInit {
|
||||
duplicateOf && this.entries().length
|
||||
? this.entries().find((entry) => entry.id === duplicateOf) ?? null
|
||||
: null;
|
||||
const nextEntryPanelMode: EntryPanelMode = duplicateSource ? 'duplicate' : 'create';
|
||||
const nextDuplicateSourceId = duplicateSource?.id ?? null;
|
||||
const shouldRehydrateDraft =
|
||||
this.entryPanelMode() !== nextEntryPanelMode ||
|
||||
this.duplicateSourceId() !== nextDuplicateSourceId ||
|
||||
!this.entryForm.dirty;
|
||||
|
||||
this.selectedEntryId.set(null);
|
||||
this.duplicateSourceId.set(duplicateSource?.id ?? null);
|
||||
this.entryPanelMode.set(duplicateSource ? 'duplicate' : 'create');
|
||||
this.duplicateSourceId.set(nextDuplicateSourceId);
|
||||
this.entryPanelMode.set(nextEntryPanelMode);
|
||||
|
||||
if (duplicateSource) {
|
||||
this.seedDuplicateForm(duplicateSource);
|
||||
} else {
|
||||
this.resetEntryForm();
|
||||
if (shouldRehydrateDraft) {
|
||||
if (duplicateSource) {
|
||||
this.seedDuplicateForm(duplicateSource);
|
||||
} else {
|
||||
this.resetEntryForm();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1105,6 +1112,50 @@ export class WatchlistPageComponent implements OnInit {
|
||||
return !!entry && toScopeFilter(entry.scope) === scope;
|
||||
}
|
||||
|
||||
private buildUpdateRequest(
|
||||
entry: WatchedIdentity,
|
||||
overrides: Partial<WatchlistEntryRequest>
|
||||
): WatchlistEntryRequest {
|
||||
return {
|
||||
channelOverrides: hasOverride(overrides, 'channelOverrides')
|
||||
? overrides.channelOverrides
|
||||
: entry.channelOverrides,
|
||||
description: hasOverride(overrides, 'description')
|
||||
? overrides.description
|
||||
: entry.description,
|
||||
displayName: hasOverride(overrides, 'displayName')
|
||||
? overrides.displayName ?? entry.displayName
|
||||
: entry.displayName,
|
||||
enabled: hasOverride(overrides, 'enabled')
|
||||
? overrides.enabled
|
||||
: entry.enabled,
|
||||
issuer: hasOverride(overrides, 'issuer')
|
||||
? overrides.issuer
|
||||
: entry.issuer,
|
||||
keyId: hasOverride(overrides, 'keyId')
|
||||
? overrides.keyId
|
||||
: entry.keyId,
|
||||
matchMode: hasOverride(overrides, 'matchMode')
|
||||
? overrides.matchMode
|
||||
: entry.matchMode,
|
||||
scope: hasOverride(overrides, 'scope')
|
||||
? overrides.scope
|
||||
: entry.scope,
|
||||
severity: hasOverride(overrides, 'severity')
|
||||
? overrides.severity
|
||||
: entry.severity,
|
||||
subjectAlternativeName: hasOverride(overrides, 'subjectAlternativeName')
|
||||
? overrides.subjectAlternativeName
|
||||
: entry.subjectAlternativeName,
|
||||
suppressDuplicatesMinutes: hasOverride(overrides, 'suppressDuplicatesMinutes')
|
||||
? overrides.suppressDuplicatesMinutes
|
||||
: entry.suppressDuplicatesMinutes,
|
||||
tags: hasOverride(overrides, 'tags')
|
||||
? overrides.tags
|
||||
: entry.tags,
|
||||
};
|
||||
}
|
||||
|
||||
private navigateToTab(
|
||||
tab: WatchlistTab,
|
||||
overrides: NavigationParams = {}
|
||||
@@ -1206,6 +1257,13 @@ function parseCsv(value: string): string[] | undefined {
|
||||
return items.length ? items : undefined;
|
||||
}
|
||||
|
||||
function hasOverride<Key extends keyof WatchlistEntryRequest>(
|
||||
overrides: Partial<WatchlistEntryRequest>,
|
||||
key: Key
|
||||
): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(overrides, key);
|
||||
}
|
||||
|
||||
function sortEntries(entries: readonly WatchedIdentity[]): WatchedIdentity[] {
|
||||
return [...entries].sort((left, right) => {
|
||||
if (left.enabled !== right.enabled) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"files": [
|
||||
"src/test-setup.ts",
|
||||
"src/app/core/api/first-signal.client.spec.ts",
|
||||
"src/app/core/api/watchlist.client.spec.ts",
|
||||
"src/app/core/console/console-status.service.spec.ts",
|
||||
"src/app/features/change-trace/change-trace-viewer.component.spec.ts",
|
||||
"src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts",
|
||||
|
||||
Reference in New Issue
Block a user