test(m5): playwright spec + docs (CHANGELOG, README, lessons, testing-m5)

- 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>
This commit is contained in:
Knacky
2026-05-12 19:57:51 +02:00
parent 2781ce4117
commit a559823386
6 changed files with 426 additions and 2 deletions

View File

@@ -0,0 +1,253 @@
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();
});
});