Four new spec files covering the MITRE multi-technique feature: - us13: API contract (techniques array, dedup, unknown ID → 400, SOC 403, auto-transition) - us14: tag UI (empty state, add/remove auto-save, SimulationList column, order, styling) - us15: matrix modal (tactic tree, layout, select/expand/search, Apply/Cancel/Escape/backdrop, a11y) - us16: sprint 2 regression (workflow, badge, SOC RBAC, picker still works) Updated sprint 2 specs (us8, us10) to use technique_ids array and Quick search button instead of deprecated scalar mitre_technique_id/name fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
178 lines
6.3 KiB
TypeScript
178 lines
6.3 KiB
TypeScript
/**
|
|
* US-10 — MITRE ATT&CK autocomplete.
|
|
* Covers AC-10.1 → AC-10.5.
|
|
*
|
|
* AC-10.1 (make update-mitre CLI target) is not exercised from Playwright;
|
|
* the bundle is assumed present in the container image.
|
|
*/
|
|
import { test, expect } from '@playwright/test';
|
|
import {
|
|
adminToken,
|
|
createEngagement,
|
|
deleteEngagement,
|
|
deleteUserByUsername,
|
|
ensureUser,
|
|
login,
|
|
makeClient,
|
|
} from '../fixtures/api';
|
|
import { seedTokenInStorage } from '../fixtures/auth';
|
|
|
|
const REDTEAM_USER = 'us10-redteam';
|
|
const PASS = 'us10-pass-strong';
|
|
|
|
interface Simulation {
|
|
id: number;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
async function createSimulation(
|
|
token: string,
|
|
engagementId: number,
|
|
name = 'US-10 sim',
|
|
): Promise<Simulation> {
|
|
const client = makeClient(token);
|
|
const r = await client.post(`/engagements/${engagementId}/simulations`, { name });
|
|
if (r.status !== 201) {
|
|
throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`);
|
|
}
|
|
return r.data as Simulation;
|
|
}
|
|
|
|
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
|
const client = makeClient(token);
|
|
await client.delete(`/simulations/${simId}`);
|
|
}
|
|
|
|
test.describe('US-10 — MITRE autocomplete', () => {
|
|
let redteamToken: string;
|
|
let engagementId: number;
|
|
|
|
test.beforeAll(async () => {
|
|
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
|
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
|
const eng = await createEngagement(redteamToken, {
|
|
name: 'US-10 Test Engagement',
|
|
start_date: '2026-01-01',
|
|
});
|
|
engagementId = eng.id;
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
try {
|
|
const tok = await adminToken();
|
|
await deleteEngagement(tok, engagementId);
|
|
await deleteUserByUsername(tok, REDTEAM_USER);
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
});
|
|
|
|
test('AC-10.1 — bundle present in container (skipped: CLI-only, bundle assumed committed)', async () => {
|
|
// AC-10.1 is a Makefile target test. We verify the bundle is loaded
|
|
// indirectly by checking the API returns results (not 503).
|
|
const client = makeClient(redteamToken);
|
|
const r = await client.get('/mitre/techniques?q=T1059');
|
|
// If bundle not loaded, we'd get 503 — this confirms it loaded OK
|
|
expect(r.status).not.toBe(503);
|
|
});
|
|
|
|
test('AC-10.2 — GET /api/mitre/techniques?q= returns max 20 results with id/name/tactics', async () => {
|
|
const client = makeClient(redteamToken);
|
|
|
|
// Search by id prefix
|
|
const rId = await client.get('/mitre/techniques?q=T1059');
|
|
expect(rId.status).toBe(200);
|
|
expect(Array.isArray(rId.data)).toBe(true);
|
|
expect(rId.data.length).toBeGreaterThan(0);
|
|
expect(rId.data.length).toBeLessThanOrEqual(20);
|
|
|
|
const first = rId.data[0];
|
|
expect(first).toHaveProperty('id');
|
|
expect(first).toHaveProperty('name');
|
|
expect(first).toHaveProperty('tactics');
|
|
expect(Array.isArray(first.tactics)).toBe(true);
|
|
|
|
// Exact id match comes first
|
|
const exactMatch = rId.data.find((t: { id: string }) => t.id === 'T1059');
|
|
expect(exactMatch).toBeTruthy();
|
|
expect(rId.data[0].id).toBe('T1059');
|
|
|
|
// Search by name (case-insensitive)
|
|
const rName = await client.get(
|
|
'/mitre/techniques?q=command%20and%20scripting%20interpreter',
|
|
);
|
|
expect(rName.status).toBe(200);
|
|
expect(rName.data.length).toBeGreaterThan(0);
|
|
const nameMatch = rName.data.find((t: { name: string }) =>
|
|
t.name.toLowerCase().includes('command and scripting'),
|
|
);
|
|
expect(nameMatch).toBeTruthy();
|
|
});
|
|
|
|
test('AC-10.3 — 503 if bundle not loaded (verified by absence: bundle IS loaded)', async () => {
|
|
const client = makeClient(redteamToken);
|
|
// We can only test the happy path from e2e; 503 requires a container
|
|
// without the bundle. We verify the endpoint does NOT return 503,
|
|
// confirming the bundle is loaded.
|
|
const r = await client.get('/mitre/techniques?q=T1');
|
|
expect(r.status).toBe(200);
|
|
});
|
|
|
|
test('AC-10.4 — sub-techniques (T1059.001) included in search results', async () => {
|
|
const client = makeClient(redteamToken);
|
|
const r = await client.get('/mitre/techniques?q=T1059.001');
|
|
expect(r.status).toBe(200);
|
|
expect(r.data.length).toBeGreaterThan(0);
|
|
const subtech = r.data.find((t: { id: string }) => t.id === 'T1059.001');
|
|
expect(subtech).toBeTruthy();
|
|
expect(subtech.name).toBeTruthy();
|
|
});
|
|
|
|
test('AC-10.5 — MitreTechniquePicker: input, dropdown, keyboard nav, selection appends tag', async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-10.5 sim');
|
|
|
|
await seedTokenInStorage(context, redteamToken);
|
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
|
|
|
// Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search"
|
|
await page.getByRole('button', { name: /quick search/i }).click();
|
|
|
|
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
|
await expect(picker).toBeVisible();
|
|
|
|
// Type a query — after debounce (200ms) the dropdown opens with results
|
|
await picker.fill('T1059');
|
|
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
|
|
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Options visible in expected format: "T1059 — Command and Scripting Interpreter (...)"
|
|
const options = listbox.getByRole('option');
|
|
await expect(options.first()).toBeVisible();
|
|
const firstText = await options.first().textContent();
|
|
expect(firstText).toMatch(/T1059/);
|
|
expect(firstText).toMatch(/—/);
|
|
|
|
// Keyboard navigation: ArrowDown selects item, Enter confirms
|
|
await picker.press('ArrowDown');
|
|
await picker.press('Enter');
|
|
|
|
// Sprint 3: after selection the picker resets (one-shot append mode).
|
|
// The tag T1059 should appear in the techniques field.
|
|
await expect(listbox).not.toBeVisible();
|
|
await expect(page.getByTestId('techniques-tag-list')).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059');
|
|
|
|
// Escape closes the dropdown (re-open picker to test Escape)
|
|
await page.getByRole('button', { name: /quick search/i }).click();
|
|
await picker.fill('T1');
|
|
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
|
await picker.press('Escape');
|
|
await expect(listbox).not.toBeVisible();
|
|
|
|
await deleteSimulation(redteamToken, sim.id);
|
|
});
|
|
});
|