- 4 Playwright tests: API CRUD round-trip, scenario reorder via PUT, SPA list + opsec filter, SPA scenario list rendering with ordered tests. - afterAll restores the stable admin (admin@metamorph.local) per the test_admin memory rule. - CHANGELOG M5 section + Fixed subsections for the LogRecord 'name' collision and the React `currentTarget` vs `target` quirk. - README status bumps to M0-M5. - tasks/lessons.md captures the new patterns (sentinel pattern for partial-update, FK ordering in /diag/reset, dnd-kit stable IDs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
254 lines
9.5 KiB
TypeScript
254 lines
9.5 KiB
TypeScript
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
|
|
|
|
/**
|
|
* M5 — Test + Scenario template catalogue.
|
|
*
|
|
* Verifies CRUD on /test-templates and /scenario-templates plus the admin SPA
|
|
* pages. We do NOT seed the full MITRE bundle here — M4 already covers that
|
|
* suite. This spec only needs ONE technique resolvable from a STIX-like
|
|
* shape (we ride on the same `/diag/reset` then re-seed MITRE so tag refs
|
|
* resolve).
|
|
*/
|
|
|
|
const ADMIN_EMAIL = `admin-${crypto.randomUUID().slice(0, 8)}@metamorph.local`;
|
|
const ADMIN_PASSWORD = 'AdminPass1234!';
|
|
|
|
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
|
|
const r = await request.post('/api/v1/diag/reset');
|
|
expect(r.status()).toBe(200);
|
|
return (await r.json()).install_token as string;
|
|
}
|
|
|
|
async function loginAndGetAccess(
|
|
request: APIRequestContext,
|
|
email: string,
|
|
password: string,
|
|
): Promise<string> {
|
|
const r = await request.post('/api/v1/auth/login', { data: { email, password } });
|
|
expect(r.status()).toBe(200);
|
|
return (await r.json()).access_token as string;
|
|
}
|
|
|
|
async function loginViaSpa(page: Page, email: string, password: string) {
|
|
await page.goto('/login');
|
|
await page.getByLabel(/email/i).fill(email);
|
|
await page.getByLabel(/password/i).fill(password);
|
|
await page.getByRole('button', { name: /sign in/i }).click();
|
|
await expect(page.getByTestId('me-email')).toHaveText(email);
|
|
}
|
|
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
test.describe('M5 — Template catalogue', () => {
|
|
test.beforeAll(async ({ request }) => {
|
|
const installToken = await resetAndMintToken(request);
|
|
const setup = await request.post('/api/v1/setup', {
|
|
data: { install_token: installToken, email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
|
});
|
|
expect(setup.status()).toBe(201);
|
|
// MITRE re-sync — picker + tag refs rely on the canonical bundle.
|
|
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
const sync = await request.post('/api/v1/mitre/sync', {
|
|
headers: { Authorization: `Bearer ${access}` },
|
|
});
|
|
expect(sync.status()).toBe(200);
|
|
});
|
|
|
|
test.afterAll(async ({ request }) => {
|
|
// Restore the stable admin (cf. memory feedback_metamorph_test_admin):
|
|
// any wipe should leave admin@metamorph.local / AdminPass1234! usable.
|
|
const installToken = await resetAndMintToken(request);
|
|
await request.post('/api/v1/setup', {
|
|
data: {
|
|
install_token: installToken,
|
|
email: 'admin@metamorph.local',
|
|
password: 'AdminPass1234!',
|
|
},
|
|
});
|
|
// Re-seed MITRE so subsequent manual sessions don't see an empty matrix.
|
|
const access = await loginAndGetAccess(request, 'admin@metamorph.local', 'AdminPass1234!');
|
|
await request.post('/api/v1/mitre/sync', {
|
|
headers: { Authorization: `Bearer ${access}` },
|
|
});
|
|
});
|
|
|
|
// === API smoke ============================================================
|
|
|
|
test('CRUD test-templates via API', async ({ request }) => {
|
|
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
const auth = { Authorization: `Bearer ${access}` };
|
|
|
|
// Create
|
|
const r1 = await request.post('/api/v1/test-templates', {
|
|
headers: auth,
|
|
data: {
|
|
name: 'phish-link',
|
|
description: 'send a phishing email with tracked link',
|
|
objective: 'land a click',
|
|
procedure_md: '1. craft mail\n2. send\n3. await click',
|
|
opsec_level: 'low',
|
|
tags: ['phish', 'initial-access'],
|
|
expected_iocs: ['phish@example.com'],
|
|
mitre_tags: [
|
|
{ kind: 'tactic', external_id: 'TA0001' },
|
|
{ kind: 'technique', external_id: 'T1566' },
|
|
],
|
|
},
|
|
});
|
|
expect(r1.status(), await r1.text()).toBe(201);
|
|
const created = await r1.json();
|
|
expect(created.name).toBe('phish-link');
|
|
expect(created.mitre_tags.length).toBe(2);
|
|
expect(created.tags).toContain('phish');
|
|
|
|
// Update — partial: change opsec only
|
|
const r2 = await request.put(`/api/v1/test-templates/${created.id}`, {
|
|
headers: auth,
|
|
data: { opsec_level: 'high' },
|
|
});
|
|
expect(r2.status()).toBe(200);
|
|
const updated = await r2.json();
|
|
expect(updated.opsec_level).toBe('high');
|
|
expect(updated.name).toBe('phish-link'); // untouched
|
|
|
|
// List + filter by tactic
|
|
const r3 = await request.get('/api/v1/test-templates?tactic=TA0001', {
|
|
headers: auth,
|
|
});
|
|
expect(r3.status()).toBe(200);
|
|
const list = await r3.json();
|
|
expect(list.items.map((it: { name: string }) => it.name)).toContain('phish-link');
|
|
|
|
// Reject unknown MITRE
|
|
const r4 = await request.post('/api/v1/test-templates', {
|
|
headers: auth,
|
|
data: {
|
|
name: 'bad',
|
|
mitre_tags: [{ kind: 'technique', external_id: 'T9999' }],
|
|
},
|
|
});
|
|
expect(r4.status()).toBe(400);
|
|
expect((await r4.json()).error).toBe('unknown_mitre_tag');
|
|
|
|
// Soft-delete
|
|
const r5 = await request.delete(`/api/v1/test-templates/${created.id}`, {
|
|
headers: auth,
|
|
});
|
|
expect(r5.status()).toBe(200);
|
|
const r6 = await request.get('/api/v1/test-templates', { headers: auth });
|
|
expect(
|
|
(await r6.json()).items.map((it: { name: string }) => it.name),
|
|
).not.toContain('phish-link');
|
|
});
|
|
|
|
test('Scenario template: create + reorder + soft-delete', async ({ request }) => {
|
|
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
const auth = { Authorization: `Bearer ${access}` };
|
|
|
|
async function mkTest(name: string): Promise<string> {
|
|
const r = await request.post('/api/v1/test-templates', {
|
|
headers: auth,
|
|
data: { name },
|
|
});
|
|
expect(r.status()).toBe(201);
|
|
return (await r.json()).id as string;
|
|
}
|
|
|
|
const a = await mkTest('scn-step-a');
|
|
const b = await mkTest('scn-step-b');
|
|
const c = await mkTest('scn-step-c');
|
|
|
|
// Create with [a, b, c]
|
|
const r1 = await request.post('/api/v1/scenario-templates', {
|
|
headers: auth,
|
|
data: { name: 'ordered-scenario', test_template_ids: [a, b, c] },
|
|
});
|
|
expect(r1.status()).toBe(201);
|
|
const sc = await r1.json();
|
|
expect(sc.tests.map((t: { test_template_name: string }) => t.test_template_name)).toEqual([
|
|
'scn-step-a',
|
|
'scn-step-b',
|
|
'scn-step-c',
|
|
]);
|
|
|
|
// Reorder → [c, a, b]
|
|
const r2 = await request.put(`/api/v1/scenario-templates/${sc.id}/tests`, {
|
|
headers: auth,
|
|
data: { test_template_ids: [c, a, b] },
|
|
});
|
|
expect(r2.status()).toBe(200);
|
|
const after = await r2.json();
|
|
expect(after.tests.map((t: { test_template_name: string }) => t.test_template_name)).toEqual([
|
|
'scn-step-c',
|
|
'scn-step-a',
|
|
'scn-step-b',
|
|
]);
|
|
|
|
// Soft-delete the scenario.
|
|
const r3 = await request.delete(`/api/v1/scenario-templates/${sc.id}`, { headers: auth });
|
|
expect(r3.status()).toBe(200);
|
|
const list = await (await request.get('/api/v1/scenario-templates', { headers: auth })).json();
|
|
expect(list.items.map((it: { name: string }) => it.name)).not.toContain('ordered-scenario');
|
|
});
|
|
|
|
// === SPA smoke ============================================================
|
|
|
|
test('SPA — admin sees the test catalogue and can filter', async ({ page, request }) => {
|
|
// Seed two tests up front via the API — exercise the SPA list + filter
|
|
// pipeline without fighting the heavy create-modal (covered by API tests).
|
|
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
const auth = { Authorization: `Bearer ${access}` };
|
|
await request.post('/api/v1/test-templates', {
|
|
headers: auth,
|
|
data: { name: 'spa-list-fast', opsec_level: 'low', tags: ['fast'] },
|
|
});
|
|
await request.post('/api/v1/test-templates', {
|
|
headers: auth,
|
|
data: { name: 'spa-list-slow', opsec_level: 'high' },
|
|
});
|
|
|
|
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
await page.goto('/admin/tests');
|
|
|
|
await expect(page.getByText('spa-list-fast')).toBeVisible();
|
|
await expect(page.getByText('spa-list-slow')).toBeVisible();
|
|
|
|
await page.getByTestId('filter-opsec').selectOption('high');
|
|
await expect(page.getByText('spa-list-slow')).toBeVisible();
|
|
await expect(page.getByText('spa-list-fast')).toBeHidden();
|
|
});
|
|
|
|
test('SPA — scenario list shows ordered tests with their position', async ({ page, request }) => {
|
|
// Seed a 3-test scenario via the API; the SPA must render the order as
|
|
// saved. Pointer-event drag is flaky in CI, and the API-level reorder
|
|
// test already covers the persistence pipeline.
|
|
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
const auth = { Authorization: `Bearer ${access}` };
|
|
const ids: string[] = [];
|
|
for (const name of ['drag-1', 'drag-2', 'drag-3']) {
|
|
const r = await request.post('/api/v1/test-templates', {
|
|
headers: auth,
|
|
data: { name },
|
|
});
|
|
ids.push((await r.json()).id);
|
|
}
|
|
const scResp = await request.post('/api/v1/scenario-templates', {
|
|
headers: auth,
|
|
data: {
|
|
name: 'spa-rendered-scenario',
|
|
test_template_ids: [ids[2], ids[0], ids[1]],
|
|
},
|
|
});
|
|
const scId = (await scResp.json()).id;
|
|
|
|
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
await page.goto('/admin/scenarios');
|
|
|
|
const card = page.locator(`[data-testid="scenario-row-${scId}"]`);
|
|
await expect(card).toBeVisible();
|
|
await expect(card.getByText('1. drag-3')).toBeVisible();
|
|
await expect(card.getByText('2. drag-1')).toBeVisible();
|
|
await expect(card.getByText('3. drag-2')).toBeVisible();
|
|
});
|
|
});
|