test(m4): pytest parser + endpoints + e2e tag picker
- backend/tests/test_mitre.py: 12 integration tests using a hand-crafted
minimal STIX bundle (no network in tests). Covers parser
(revoked/deprecated skip, sub-technique parent linkage), seed idempotence,
persisted settings, checksum mismatch path, all four read endpoints, perm
enforcement on /mitre/sync, ILIKE search.
- e2e/tests/m4-mitre.spec.ts: 6 Playwright tests against the live stack.
beforeAll calls POST /mitre/sync once (real bundle, ~50 MB, ~1.1 s) then
the suite validates tactics ≥14, T1003 has ≥5 sub-techniques, the picker
walks tactic→technique→subtechnique with chip multi-select, and non-admin
sees /mitre but no Sync card.
- tasks/testing-m4.md: manual + automated checklist, air-gapped operator
notes, volume-permission caveat for pre-existing root-owned volumes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:54:26 +02:00
|
|
|
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* M4 — MITRE ATT&CK Enterprise reference catalogue + tag picker.
|
|
|
|
|
*
|
|
|
|
|
* The seed itself (download + parse) is exercised by pytest with a small
|
|
|
|
|
* fixture bundle. This spec hits the live stack with the real, pinned bundle
|
|
|
|
|
* by calling `POST /mitre/sync` once and then validating the read endpoints
|
|
|
|
|
* + the picker UI.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@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('M4 — MITRE ATT&CK reference', () => {
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// Trigger a real sync against the pinned MITRE URL. Idempotent — if the
|
|
|
|
|
// mitre_* tables were left populated by a previous run, this is a no-op
|
|
|
|
|
// upsert.
|
|
|
|
|
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(), `mitre sync failed: ${await sync.text()}`).toBe(200);
|
|
|
|
|
const result = await sync.json();
|
|
|
|
|
expect(result.tactics_upserted).toBeGreaterThanOrEqual(14);
|
|
|
|
|
expect(result.techniques_upserted).toBeGreaterThanOrEqual(180);
|
|
|
|
|
expect(result.subtechniques_upserted).toBeGreaterThanOrEqual(400);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('GET /mitre/tactics returns 14+ Enterprise tactics', async ({ request }) => {
|
|
|
|
|
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
|
|
|
const r = await request.get('/api/v1/mitre/tactics', {
|
|
|
|
|
headers: { Authorization: `Bearer ${access}` },
|
|
|
|
|
});
|
|
|
|
|
expect(r.status()).toBe(200);
|
|
|
|
|
const body = await r.json();
|
|
|
|
|
expect(body.total).toBeGreaterThanOrEqual(14);
|
|
|
|
|
const ids = body.items.map((t: { external_id: string }) => t.external_id);
|
|
|
|
|
expect(ids).toContain('TA0001'); // Initial Access
|
|
|
|
|
expect(ids).toContain('TA0006'); // Credential Access
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('GET /mitre/techniques?tactic=TA0006 filters', async ({ request }) => {
|
|
|
|
|
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
|
|
|
const r = await request.get('/api/v1/mitre/techniques?tactic=TA0006&limit=200', {
|
|
|
|
|
headers: { Authorization: `Bearer ${access}` },
|
|
|
|
|
});
|
|
|
|
|
expect(r.status()).toBe(200);
|
|
|
|
|
const body = await r.json();
|
|
|
|
|
expect(body.total).toBeGreaterThan(0);
|
|
|
|
|
// OS Credential Dumping is the textbook TA0006 example.
|
|
|
|
|
const ids = body.items.map((t: { external_id: string }) => t.external_id);
|
|
|
|
|
expect(ids).toContain('T1003');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('GET /mitre/subtechniques?technique=T1003 lists 8 sub-techniques', async ({ request }) => {
|
|
|
|
|
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
|
|
|
const r = await request.get('/api/v1/mitre/subtechniques?technique=T1003', {
|
|
|
|
|
headers: { Authorization: `Bearer ${access}` },
|
|
|
|
|
});
|
|
|
|
|
expect(r.status()).toBe(200);
|
|
|
|
|
const ids = (await r.json()).items.map((t: { external_id: string }) => t.external_id);
|
|
|
|
|
expect(ids).toContain('T1003.001'); // LSASS Memory
|
|
|
|
|
expect(ids.length).toBeGreaterThanOrEqual(5);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('GET /mitre/status returns version + last_sync', async ({ request }) => {
|
|
|
|
|
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
|
|
|
const r = await request.get('/api/v1/mitre/status', {
|
|
|
|
|
headers: { Authorization: `Bearer ${access}` },
|
|
|
|
|
});
|
|
|
|
|
expect(r.status()).toBe(200);
|
|
|
|
|
const body = await r.json();
|
|
|
|
|
expect(body.last_sync).toBeTruthy();
|
|
|
|
|
expect(body.default_url).toContain('mitre-attack');
|
|
|
|
|
expect(body.default_version).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-12 18:32:20 +02:00
|
|
|
test('SPA MITRE matrix renders + click cells to select technique + sub-technique', async ({ page }) => {
|
test(m4): pytest parser + endpoints + e2e tag picker
- backend/tests/test_mitre.py: 12 integration tests using a hand-crafted
minimal STIX bundle (no network in tests). Covers parser
(revoked/deprecated skip, sub-technique parent linkage), seed idempotence,
persisted settings, checksum mismatch path, all four read endpoints, perm
enforcement on /mitre/sync, ILIKE search.
- e2e/tests/m4-mitre.spec.ts: 6 Playwright tests against the live stack.
beforeAll calls POST /mitre/sync once (real bundle, ~50 MB, ~1.1 s) then
the suite validates tactics ≥14, T1003 has ≥5 sub-techniques, the picker
walks tactic→technique→subtechnique with chip multi-select, and non-admin
sees /mitre but no Sync card.
- tasks/testing-m4.md: manual + automated checklist, air-gapped operator
notes, volume-permission caveat for pre-existing root-owned volumes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:54:26 +02:00
|
|
|
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
|
|
|
await page.goto('/mitre');
|
|
|
|
|
|
|
|
|
|
// Status card shows a non-null last_sync.
|
|
|
|
|
await expect(page.getByTestId('mitre-last-sync')).not.toHaveText('never');
|
|
|
|
|
|
|
|
|
|
const picker = page.getByTestId('mitre-tag-picker');
|
|
|
|
|
await expect(picker).toBeVisible();
|
2026-05-12 18:32:20 +02:00
|
|
|
// The matrix has a column per tactic.
|
|
|
|
|
await expect(picker.getByTestId('mitre-column-TA0006')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// 1. Click the T1003 cell (Credential Dumping under TA0006) → technique selected.
|
|
|
|
|
const t1003 = picker.getByTestId('mitre-technique-T1003').first();
|
|
|
|
|
await t1003.scrollIntoViewIfNeeded();
|
|
|
|
|
await t1003.click();
|
|
|
|
|
await expect(page.getByTestId('mitre-selected')).toContainText('T1003');
|
|
|
|
|
await expect(t1003).toHaveAttribute('aria-pressed', 'true');
|
|
|
|
|
|
|
|
|
|
// 2. Expand T1003's sub-techniques inline via the +N chevron.
|
|
|
|
|
await picker.getByTestId('mitre-expand-T1003').first().click();
|
|
|
|
|
const sub = picker.getByTestId('mitre-subtechnique-T1003.001').first();
|
|
|
|
|
await expect(sub).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// 3. Click the sub-technique → chip + JSON preview both update.
|
|
|
|
|
await sub.click();
|
test(m4): pytest parser + endpoints + e2e tag picker
- backend/tests/test_mitre.py: 12 integration tests using a hand-crafted
minimal STIX bundle (no network in tests). Covers parser
(revoked/deprecated skip, sub-technique parent linkage), seed idempotence,
persisted settings, checksum mismatch path, all four read endpoints, perm
enforcement on /mitre/sync, ILIKE search.
- e2e/tests/m4-mitre.spec.ts: 6 Playwright tests against the live stack.
beforeAll calls POST /mitre/sync once (real bundle, ~50 MB, ~1.1 s) then
the suite validates tactics ≥14, T1003 has ≥5 sub-techniques, the picker
walks tactic→technique→subtechnique with chip multi-select, and non-admin
sees /mitre but no Sync card.
- tasks/testing-m4.md: manual + automated checklist, air-gapped operator
notes, volume-permission caveat for pre-existing root-owned volumes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:54:26 +02:00
|
|
|
await expect(page.getByTestId('mitre-selected')).toContainText('T1003.001');
|
|
|
|
|
await expect(page.getByTestId('mitre-selected-json')).toContainText('"T1003.001"');
|
2026-05-12 18:32:20 +02:00
|
|
|
|
|
|
|
|
// 4. Filter the matrix on "valid" → TA0006/T1003 are hidden but TA0001/T1078 visible.
|
|
|
|
|
await picker.getByLabel(/^filter$/i).fill('valid');
|
|
|
|
|
await expect(picker.getByTestId('mitre-technique-T1078').first()).toBeVisible();
|
test(m4): pytest parser + endpoints + e2e tag picker
- backend/tests/test_mitre.py: 12 integration tests using a hand-crafted
minimal STIX bundle (no network in tests). Covers parser
(revoked/deprecated skip, sub-technique parent linkage), seed idempotence,
persisted settings, checksum mismatch path, all four read endpoints, perm
enforcement on /mitre/sync, ILIKE search.
- e2e/tests/m4-mitre.spec.ts: 6 Playwright tests against the live stack.
beforeAll calls POST /mitre/sync once (real bundle, ~50 MB, ~1.1 s) then
the suite validates tactics ≥14, T1003 has ≥5 sub-techniques, the picker
walks tactic→technique→subtechnique with chip multi-select, and non-admin
sees /mitre but no Sync card.
- tasks/testing-m4.md: manual + automated checklist, air-gapped operator
notes, volume-permission caveat for pre-existing root-owned volumes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:54:26 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Non-admin cannot trigger /mitre/sync', async ({ page, request }) => {
|
|
|
|
|
// Invite a no-perm user via the admin.
|
|
|
|
|
const adminAccess = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
|
|
|
|
const eveEmail = `eve-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
|
|
|
|
|
const inv = await request.post('/api/v1/invitations', {
|
|
|
|
|
headers: { Authorization: `Bearer ${adminAccess}` },
|
|
|
|
|
data: { email_hint: eveEmail },
|
|
|
|
|
});
|
|
|
|
|
const token = (await inv.json()).token;
|
|
|
|
|
await request.post(`/api/v1/invitations/accept/${token}`, {
|
|
|
|
|
data: { email: eveEmail, password: 'EvePass1234!' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const eveAccess = await loginAndGetAccess(request, eveEmail, 'EvePass1234!');
|
|
|
|
|
const r = await request.post('/api/v1/mitre/sync', {
|
|
|
|
|
headers: { Authorization: `Bearer ${eveAccess}` },
|
|
|
|
|
});
|
|
|
|
|
expect(r.status()).toBe(403);
|
|
|
|
|
|
|
|
|
|
// The MITRE page is reachable in read-only mode for any logged-in user,
|
|
|
|
|
// but the Sync card is hidden for non-admins.
|
|
|
|
|
await loginViaSpa(page, eveEmail, 'EvePass1234!');
|
|
|
|
|
await page.goto('/mitre');
|
|
|
|
|
await expect(page.getByTestId('mitre-tag-picker')).toBeVisible();
|
|
|
|
|
await expect(page.getByTestId('mitre-sync')).toHaveCount(0);
|
|
|
|
|
});
|
|
|
|
|
});
|