Fix watchlist draft hydration and update contracts
This commit is contained in:
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