sprint/2-simulations #3

Merged
knacky merged 8 commits from sprint/2-simulations into main 2026-05-26 10:14:36 +00:00
6 changed files with 1180 additions and 0 deletions
Showing only changes of commit da905cc0a0 - Show all commits

View File

@@ -0,0 +1,174 @@
/**
* 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 fills both fields', 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`);
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');
// Wait for dropdown to appear (debounce + network)
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');
// After selection the dropdown closes and input shows the selected value
await expect(listbox).not.toBeVisible();
const inputValue = await picker.inputValue();
expect(inputValue).toMatch(/T1059/);
expect(inputValue).toMatch(/—/);
// Escape closes the dropdown
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);
});
});

View File

@@ -0,0 +1,245 @@
/**
* US-11 — workflow transitions.
* Covers AC-11.1 → AC-11.5.
*/
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 = 'us11-redteam';
const SOC_USER = 'us11-soc';
const PASS = 'us11-pass-strong';
interface Simulation {
id: number;
status: string;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-11 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-11 — workflow transitions', () => {
let redteamToken: string;
let socToken: string;
let engagementId: number;
test.beforeAll(async () => {
await ensureUser(REDTEAM_USER, PASS, 'redteam');
await ensureUser(SOC_USER, PASS, 'soc');
redteamToken = (await login(REDTEAM_USER, PASS)).token;
socToken = (await login(SOC_USER, PASS)).token;
const eng = await createEngagement(redteamToken, {
name: 'US-11 Test Engagement',
start_date: '2026-01-01',
});
engagementId = eng.id;
});
test.afterAll(async () => {
try {
const tok = await adminToken();
await deleteEngagement(tok, engagementId);
for (const u of [REDTEAM_USER, SOC_USER]) {
await deleteUserByUsername(tok, u);
}
} catch {
/* noop */
}
});
test('AC-11.1 — pending→review_required valid (redteam); invalid target → 409', async () => {
const rtClient = makeClient(redteamToken);
// pending → review_required: valid
const sim = await createSimulation(redteamToken, engagementId, 'AC-11.1 sim');
const rOk = await rtClient.post(`/simulations/${sim.id}/transition`, {
to: 'review_required',
});
expect(rOk.status).toBe(200);
expect(rOk.data.status).toBe('review_required');
// Invalid target → 409
const simBad = await createSimulation(redteamToken, engagementId, 'AC-11.1 bad sim');
const rBad = await rtClient.post(`/simulations/${simBad.id}/transition`, {
to: 'done',
});
expect(rBad.status).toBe(409);
expect(rBad.data.error).toMatch(/invalid transition/i);
await deleteSimulation(redteamToken, sim.id);
await deleteSimulation(redteamToken, simBad.id);
});
test('AC-11.1 — in_progress→review_required valid (redteam)', async () => {
const rtClient = makeClient(redteamToken);
const sim = await createSimulation(redteamToken, engagementId, 'AC-11.1 in_progress sim');
// Trigger in_progress via auto-transition
await rtClient.patch(`/simulations/${sim.id}`, { name: 'trigger' });
const r = await rtClient.post(`/simulations/${sim.id}/transition`, {
to: 'review_required',
});
expect(r.status).toBe(200);
expect(r.data.status).toBe('review_required');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-11.2 — review_required→done valid for redteam and soc', async () => {
const rtClient = makeClient(redteamToken);
const socClient = makeClient(socToken);
// redteam can close
const simRT = await createSimulation(redteamToken, engagementId, 'AC-11.2 redteam close');
await rtClient.post(`/simulations/${simRT.id}/transition`, { to: 'review_required' });
const rRT = await rtClient.post(`/simulations/${simRT.id}/transition`, { to: 'done' });
expect(rRT.status).toBe(200);
expect(rRT.data.status).toBe('done');
// soc can close
const simSOC = await createSimulation(redteamToken, engagementId, 'AC-11.2 soc close');
await rtClient.post(`/simulations/${simSOC.id}/transition`, { to: 'review_required' });
const rSOC = await socClient.post(`/simulations/${simSOC.id}/transition`, { to: 'done' });
expect(rSOC.status).toBe(200);
expect(rSOC.data.status).toBe('done');
// done → review_required is invalid (409)
const rBack = await rtClient.post(`/simulations/${simRT.id}/transition`, {
to: 'review_required',
});
expect(rBack.status).toBe(409);
await deleteSimulation(redteamToken, simRT.id);
await deleteSimulation(redteamToken, simSOC.id);
});
test('AC-11.3 — no backward transitions; no →pending or →in_progress via endpoint', async () => {
const rtClient = makeClient(redteamToken);
const sim = await createSimulation(redteamToken, engagementId, 'AC-11.3 sim');
// done → pending: invalid
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'done' });
const rPending = await rtClient.post(`/simulations/${sim.id}/transition`, {
to: 'pending',
});
expect(rPending.status).toBe(409);
expect(rPending.data.error).toMatch(/invalid transition/i);
// →in_progress via endpoint is always invalid
const simNew = await createSimulation(redteamToken, engagementId, 'AC-11.3 in_progress');
const rIP = await rtClient.post(`/simulations/${simNew.id}/transition`, {
to: 'in_progress',
});
expect(rIP.status).toBe(409);
await deleteSimulation(redteamToken, sim.id);
await deleteSimulation(redteamToken, simNew.id);
});
test('AC-11.4 — workflow buttons visible per role+status in UI', async ({
page,
context,
}) => {
const rtClient = makeClient(redteamToken);
// pending → "Marquer en revue" visible for redteam; "Clôturer" hidden
const simPending = await createSimulation(
redteamToken,
engagementId,
'AC-11.4 pending UI',
);
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${simPending.id}/edit`);
await expect(page.getByRole('button', { name: /marquer en revue/i })).toBeVisible();
await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0);
// in_progress → "Marquer en revue" visible
const simIP = await createSimulation(redteamToken, engagementId, 'AC-11.4 in_progress UI');
await rtClient.patch(`/simulations/${simIP.id}`, { name: 'trigger' });
await page.goto(`/engagements/${engagementId}/simulations/${simIP.id}/edit`);
await expect(page.getByRole('button', { name: /marquer en revue/i })).toBeVisible();
await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0);
// review_required → "Clôturer" visible for redteam; "Marquer en revue" hidden
const simRR = await createSimulation(redteamToken, engagementId, 'AC-11.4 review UI');
await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'review_required' });
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
await expect(page.getByRole('button', { name: /clôturer/i })).toBeVisible();
await expect(page.getByRole('button', { name: /marquer en revue/i })).toHaveCount(0);
// review_required → "Clôturer" also visible for SOC
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
await expect(page.getByRole('button', { name: /clôturer/i })).toBeVisible();
// done → both buttons hidden
await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'done' });
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
await expect(page.getByRole('button', { name: /marquer en revue/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0);
await deleteSimulation(redteamToken, simPending.id);
await deleteSimulation(redteamToken, simIP.id);
await deleteSimulation(redteamToken, simRR.id);
});
test('AC-11.5 — after transition, badge updates in UI (TanStack Query invalidation)', async ({
page,
context,
}) => {
const rtClient = makeClient(redteamToken);
const sim = await createSimulation(redteamToken, engagementId, 'AC-11.5 badge sim');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Initially pending
const badge = page.getByTestId('simulation-status-badge');
await expect(badge).toHaveAttribute('data-status', 'pending');
// Click "Marquer en revue"
await page.getByRole('button', { name: /marquer en revue/i }).click();
// Badge updates to review_required without page reload
await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 });
// "Clôturer" now visible; click it
await page.getByRole('button', { name: /clôturer/i }).click();
await expect(badge).toHaveAttribute('data-status', 'done', { timeout: 5_000 });
// Verify list is also updated: navigate to engagement detail and check badge there
await page.goto(`/engagements/${engagementId}`);
const listBadge = page
.getByRole('row', { name: /AC-11.5 badge sim/i })
.getByTestId('simulation-status-badge');
await expect(listBadge).toHaveAttribute('data-status', 'done');
await deleteSimulation(redteamToken, sim.id);
});
});

View File

@@ -0,0 +1,153 @@
/**
* US-12 — simulation delete (RBAC + cascade + confirm modal).
* Covers AC-12.1 → AC-12.4.
*/
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 = 'us12-redteam';
const SOC_USER = 'us12-soc';
const PASS = 'us12-pass-strong';
interface Simulation {
id: number;
engagement_id: number;
name: string;
status: string;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-12 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;
}
test.describe('US-12 — simulation delete', () => {
let redteamToken: string;
let socToken: string;
let engagementId: number;
test.beforeAll(async () => {
await ensureUser(REDTEAM_USER, PASS, 'redteam');
await ensureUser(SOC_USER, PASS, 'soc');
redteamToken = (await login(REDTEAM_USER, PASS)).token;
socToken = (await login(SOC_USER, PASS)).token;
const eng = await createEngagement(redteamToken, {
name: 'US-12 Test Engagement',
start_date: '2026-01-01',
});
engagementId = eng.id;
});
test.afterAll(async () => {
try {
const tok = await adminToken();
await deleteEngagement(tok, engagementId);
for (const u of [REDTEAM_USER, SOC_USER]) {
await deleteUserByUsername(tok, u);
}
} catch {
/* noop */
}
});
test('AC-12.1 — DELETE /api/simulations/<sid> (redteam) → 204, then 404', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-12.1 to delete');
const client = makeClient(redteamToken);
const rDel = await client.delete(`/simulations/${sim.id}`);
expect(rDel.status).toBe(204);
const rGet = await client.get(`/simulations/${sim.id}`);
expect(rGet.status).toBe(404);
});
test('AC-12.2 — soc → 403 on DELETE', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-12.2 soc blocked');
const socClient = makeClient(socToken);
const r = await socClient.delete(`/simulations/${sim.id}`);
expect(r.status).toBe(403);
// Clean up
const rtClient = makeClient(redteamToken);
await rtClient.delete(`/simulations/${sim.id}`);
});
test('AC-12.3 — cascade: deleting engagement deletes its simulations', async () => {
const adminTok = await adminToken();
const adminClient = makeClient(adminTok);
// Create a fresh engagement with simulations
const eng = await createEngagement(redteamToken, {
name: 'US-12 cascade test',
start_date: '2026-01-01',
});
const s1 = await createSimulation(redteamToken, eng.id, 'cascade sim 1');
const s2 = await createSimulation(redteamToken, eng.id, 'cascade sim 2');
// Delete the engagement
await deleteEngagement(redteamToken, eng.id);
// Simulations must be gone
const rS1 = await adminClient.get(`/simulations/${s1.id}`);
expect(rS1.status).toBe(404);
const rS2 = await adminClient.get(`/simulations/${s2.id}`);
expect(rS2.status).toBe(404);
});
test('AC-12.4 — delete button visible for redteam, confirmation modal, deletes and redirects', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-12.4 UI delete');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Delete button is visible for redteam
const deleteBtn = page.getByRole('button', { name: /supprimer/i });
await expect(deleteBtn).toBeVisible();
// SOC should NOT see delete button
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
await expect(page.getByRole('button', { name: /supprimer/i })).toHaveCount(0);
// Back to redteam — click delete, confirm modal appears
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
await page.getByRole('button', { name: /supprimer/i }).click();
// Confirmation dialog must appear
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText(/supprimer la simulation/i)).toBeVisible();
// Confirm deletion
await dialog.getByRole('button', { name: /supprimer/i }).click();
// Should navigate back to engagement detail
await page.waitForURL(new RegExp(`/engagements/${engagementId}$`));
// Simulation no longer in list
await expect(page.getByRole('row', { name: /AC-12.4 UI delete/i })).toHaveCount(0);
});
});

View File

@@ -0,0 +1,192 @@
/**
* US-7 — redteam creates a simulation inside an engagement.
* Covers AC-7.1 → AC-7.6.
*/
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 = 'us7-redteam';
const SOC_USER = 'us7-soc';
const PASS = 'us7-pass-strong';
interface Simulation {
id: number;
engagement_id: number;
name: string;
status: string;
created_at: string;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
payload: { name: string },
): Promise<Simulation> {
const client = makeClient(token);
const r = await client.post(`/engagements/${engagementId}/simulations`, payload);
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-7 — simulation create', () => {
let redteamToken: string;
let socToken: string;
let engagementId: number;
test.beforeAll(async () => {
await ensureUser(REDTEAM_USER, PASS, 'redteam');
await ensureUser(SOC_USER, PASS, 'soc');
redteamToken = (await login(REDTEAM_USER, PASS)).token;
socToken = (await login(SOC_USER, PASS)).token;
const eng = await createEngagement(redteamToken, {
name: 'US-7 Test Engagement',
start_date: '2026-01-01',
});
engagementId = eng.id;
});
test.afterAll(async () => {
try {
const tok = await adminToken();
await deleteEngagement(tok, engagementId);
for (const u of [REDTEAM_USER, SOC_USER]) {
await deleteUserByUsername(tok, u);
}
} catch {
/* noop */
}
});
test('AC-7.1 — POST creates simulation with status pending, name required', async () => {
const sim = await createSimulation(redteamToken, engagementId, { name: 'Test sim 7.1' });
expect(sim.id).toBeTruthy();
expect(sim.engagement_id).toBe(engagementId);
expect(sim.name).toBe('Test sim 7.1');
expect(sim.status).toBe('pending');
expect(sim.created_at).toBeTruthy();
// name required: blank name → 400
const client = makeClient(redteamToken);
const r = await client.post(`/engagements/${engagementId}/simulations`, { name: '' });
expect(r.status).toBe(400);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-7.2 — soc role → 403 on POST', async () => {
const client = makeClient(socToken);
const r = await client.post(`/engagements/${engagementId}/simulations`, {
name: 'soc-blocked',
});
expect(r.status).toBe(403);
});
test('AC-7.3 — unknown engagement → 404; existing engagement with no sims → empty list', async () => {
const client = makeClient(redteamToken);
const r404 = await client.post('/engagements/999999/simulations', { name: 'ghost' });
expect(r404.status).toBe(404);
// Create a fresh engagement with no sims and verify list is empty
const freshEng = await createEngagement(redteamToken, {
name: 'US-7 empty engagement',
start_date: '2026-01-01',
});
const listR = await client.get(`/engagements/${freshEng.id}/simulations`);
expect(listR.status).toBe(200);
expect(listR.data).toEqual([]);
await deleteEngagement(redteamToken, freshEng.id);
});
test('AC-7.4 — GET list returns sims ordered by created_at desc', async () => {
const client = makeClient(redteamToken);
const s1 = await createSimulation(redteamToken, engagementId, { name: 'First sim' });
// Small delay so created_at timestamps differ
await new Promise((r) => setTimeout(r, 50));
const s2 = await createSimulation(redteamToken, engagementId, { name: 'Second sim' });
const listR = await client.get(`/engagements/${engagementId}/simulations`);
expect(listR.status).toBe(200);
const list: Simulation[] = listR.data;
expect(list.length).toBeGreaterThanOrEqual(2);
// Most recent first
const ids = list.map((s) => s.id);
expect(ids.indexOf(s2.id)).toBeLessThan(ids.indexOf(s1.id));
await deleteSimulation(redteamToken, s1.id);
await deleteSimulation(redteamToken, s2.id);
});
test('AC-7.5 — /engagements/:eid shows Simulations section with list + "Nouvelle simulation" button for redteam', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, { name: 'Visible sim' });
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
// Simulations section visible
await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible();
// Required columns
for (const col of ['Name', 'MITRE', 'Status', 'Executed at']) {
await expect(page.getByRole('columnheader', { name: new RegExp(col, 'i') })).toBeVisible();
}
// The created simulation row is visible
await expect(page.getByRole('row', { name: /Visible sim/i })).toBeVisible();
// "Nouvelle simulation" button visible for redteam
await expect(
page.getByRole('link', { name: /nouvelle simulation/i }),
).toBeVisible();
// SOC should NOT see "Nouvelle simulation" button
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}`);
await expect(page.getByRole('link', { name: /nouvelle simulation/i })).toHaveCount(0);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-7.6 — clicking a simulation row navigates to /engagements/:eid/simulations/:sid/edit', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, { name: 'Click me sim' });
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
// Click the sim name link
await page.getByRole('link', { name: 'Click me sim' }).click();
await page.waitForURL(
new RegExp(`/engagements/${engagementId}/simulations/${sim.id}/edit`),
);
await expect(page.url()).toContain(
`/engagements/${engagementId}/simulations/${sim.id}/edit`,
);
await deleteSimulation(redteamToken, sim.id);
});
});

View File

@@ -0,0 +1,216 @@
/**
* US-8 — redteam fills technical details of a simulation.
* Covers AC-8.1 → AC-8.6 (AC-8.6 defers to US-10 for the autocomplete UI detail).
*/
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 = 'us8-redteam';
const SOC_USER = 'us8-soc';
const PASS = 'us8-pass-strong';
interface Simulation {
id: number;
engagement_id: number;
name: string;
status: string;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-8 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-8 — redteam fill simulation details', () => {
let redteamToken: string;
let socToken: string;
let engagementId: number;
test.beforeAll(async () => {
await ensureUser(REDTEAM_USER, PASS, 'redteam');
await ensureUser(SOC_USER, PASS, 'soc');
redteamToken = (await login(REDTEAM_USER, PASS)).token;
socToken = (await login(SOC_USER, PASS)).token;
const eng = await createEngagement(redteamToken, {
name: 'US-8 Test Engagement',
start_date: '2026-01-01',
});
engagementId = eng.id;
});
test.afterAll(async () => {
try {
const tok = await adminToken();
await deleteEngagement(tok, engagementId);
for (const u of [REDTEAM_USER, SOC_USER]) {
await deleteUserByUsername(tok, u);
}
} catch {
/* noop */
}
});
test('AC-8.1 — PATCH accepts all redteam fields (partial OK)', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.1 sim');
const client = makeClient(redteamToken);
const patch = {
name: 'Updated name',
mitre_technique_id: 'T1059',
mitre_technique_name: 'Command and Scripting Interpreter',
description: 'Some description',
commands: 'cmd /c whoami\ncmd /c ipconfig',
prerequisites: 'Admin shell',
executed_at: '2026-05-01T12:00:00',
execution_result: 'Success',
};
const r = await client.patch(`/simulations/${sim.id}`, patch);
expect(r.status).toBe(200);
expect(r.data.name).toBe('Updated name');
expect(r.data.mitre_technique_id).toBe('T1059');
expect(r.data.mitre_technique_name).toBe('Command and Scripting Interpreter');
expect(r.data.description).toBe('Some description');
expect(r.data.commands).toBe('cmd /c whoami\ncmd /c ipconfig');
expect(r.data.prerequisites).toBe('Admin shell');
expect(r.data.execution_result).toBe('Success');
// Partial PATCH (only name) should also work
const rPartial = await client.patch(`/simulations/${sim.id}`, { name: 'Partial update' });
expect(rPartial.status).toBe(200);
expect(rPartial.data.name).toBe('Partial update');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.2 — auto-transition pending→in_progress on PATCH with non-empty redteam field', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.2 sim');
expect(sim.status).toBe('pending');
const client = makeClient(redteamToken);
// PATCH with a non-empty redteam field triggers auto-transition
const r = await client.patch(`/simulations/${sim.id}`, { name: 'trigger transition' });
expect(r.status).toBe(200);
expect(r.data.status).toBe('in_progress');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.2 — auto-transition does NOT trigger for soc PATCH', async () => {
// Create sim then transition it to review_required so SOC can patch it
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.2 soc no-trigger');
const rtClient = makeClient(redteamToken);
// Move to in_progress first
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-8.2 soc no-trigger' });
// Move to review_required
const tr = await rtClient.post(`/simulations/${sim.id}/transition`, {
to: 'review_required',
});
expect(tr.status).toBe(200);
// Now SOC patches soc-only fields — status must NOT change
const socClient = makeClient(socToken);
const rSoc = await socClient.patch(`/simulations/${sim.id}`, { soc_comment: 'noted' });
expect(rSoc.status).toBe(200);
expect(rSoc.data.status).toBe('review_required');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.3 — commands stored and returned as raw multiline string', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.3 sim');
const client = makeClient(redteamToken);
const cmds = 'cmd1\ncmd2\ncmd3';
const r = await client.patch(`/simulations/${sim.id}`, { commands: cmds });
expect(r.status).toBe(200);
expect(r.data.commands).toBe(cmds);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.4 — invalid executed_at → 400; null clears field', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.4 sim');
const client = makeClient(redteamToken);
const rBad = await client.patch(`/simulations/${sim.id}`, {
executed_at: 'not-a-date',
});
expect(rBad.status).toBe(400);
expect(rBad.data.error).toMatch(/invalid executed_at/i);
// Valid ISO 8601
const rOk = await client.patch(`/simulations/${sim.id}`, {
executed_at: '2026-05-01T09:00:00',
});
expect(rOk.status).toBe(200);
// Null clears the field
const rNull = await client.patch(`/simulations/${sim.id}`, { executed_at: null });
expect(rNull.status).toBe(200);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.5 — edit page shows Red Team and SOC sections; redteam sees both editable, name validation', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.5 sim');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Both sections visible
await expect(page.getByRole('heading', { name: /red team/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /soc/i })).toBeVisible();
// Name field is present and enabled for redteam
const nameField = page.locator('#sim-name');
await expect(nameField).toBeVisible();
await expect(nameField).toBeEnabled();
// Clear the name, try to save → client validation error
await nameField.fill('');
await page.getByRole('button', { name: /save red team/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.6 — MITRE technique picker is present on the edit form', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.6 sim');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// MitreTechniquePicker renders an input with combobox role
await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
});

View File

@@ -0,0 +1,200 @@
/**
* US-9 — SOC analyst fills their part; redteam fields blocked.
* Covers AC-9.1 → AC-9.4.
*/
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 = 'us9-redteam';
const SOC_USER = 'us9-soc';
const PASS = 'us9-pass-strong';
interface Simulation {
id: number;
status: string;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-9 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}`);
}
async function advanceToReviewRequired(
redteamToken: string,
simId: number,
): Promise<void> {
const client = makeClient(redteamToken);
// Trigger auto-transition to in_progress
await client.patch(`/simulations/${simId}`, { name: 'ready' });
// Transition to review_required
const r = await client.post(`/simulations/${simId}/transition`, {
to: 'review_required',
});
if (r.status !== 200) {
throw new Error(`transition to review_required failed: ${r.status}`);
}
}
test.describe('US-9 — SOC restricted edit', () => {
let redteamToken: string;
let socToken: string;
let engagementId: number;
test.beforeAll(async () => {
await ensureUser(REDTEAM_USER, PASS, 'redteam');
await ensureUser(SOC_USER, PASS, 'soc');
redteamToken = (await login(REDTEAM_USER, PASS)).token;
socToken = (await login(SOC_USER, PASS)).token;
const eng = await createEngagement(redteamToken, {
name: 'US-9 Test Engagement',
start_date: '2026-01-01',
});
engagementId = eng.id;
});
test.afterAll(async () => {
try {
const tok = await adminToken();
await deleteEngagement(tok, engagementId);
for (const u of [REDTEAM_USER, SOC_USER]) {
await deleteUserByUsername(tok, u);
}
} catch {
/* noop */
}
});
test('AC-9.1 — soc PATCH with redteam field → 403 soc cannot edit redteam fields', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-9.1 sim');
await advanceToReviewRequired(redteamToken, sim.id);
const socClient = makeClient(socToken);
// Redteam field in payload → 403
const r = await socClient.patch(`/simulations/${sim.id}`, {
name: 'SOC tries to change name',
});
expect(r.status).toBe(403);
expect(r.data.error).toMatch(/soc cannot edit redteam fields/i);
// SOC-only fields → 200
const rOk = await socClient.patch(`/simulations/${sim.id}`, {
soc_comment: 'Detected',
log_source: 'SIEM',
logs: 'log entry',
incident_number: 'INC-001',
});
expect(rOk.status).toBe(200);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-9.2 — soc PATCH blocked when status is pending or in_progress', async () => {
const socClient = makeClient(socToken);
// pending → 403
const simPending = await createSimulation(redteamToken, engagementId, 'AC-9.2 pending');
const rPending = await socClient.patch(`/simulations/${simPending.id}`, {
soc_comment: 'too early',
});
expect(rPending.status).toBe(403);
expect(rPending.data.error).toMatch(/simulation not ready for SOC review/i);
// in_progress → 403
const simInProgress = await createSimulation(
redteamToken,
engagementId,
'AC-9.2 in_progress',
);
const rtClient = makeClient(redteamToken);
await rtClient.patch(`/simulations/${simInProgress.id}`, { name: 'trigger' });
const rInProgress = await socClient.patch(`/simulations/${simInProgress.id}`, {
soc_comment: 'still too early',
});
expect(rInProgress.status).toBe(403);
expect(rInProgress.data.error).toMatch(/simulation not ready for SOC review/i);
await deleteSimulation(redteamToken, simPending.id);
await deleteSimulation(redteamToken, simInProgress.id);
});
test('AC-9.3 — edit page for soc: RT section read-only, SOC section editable when review_required', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-9.3 sim');
await advanceToReviewRequired(redteamToken, sim.id);
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// RT fields are disabled
await expect(page.locator('#sim-name')).toBeDisabled();
await expect(page.locator('#sim-description')).toBeDisabled();
await expect(page.locator('#sim-commands')).toBeDisabled();
// SOC fields are enabled
await expect(page.locator('#sim-log-source')).toBeEnabled();
await expect(page.locator('#sim-logs')).toBeEnabled();
await expect(page.locator('#sim-soc-comment')).toBeEnabled();
await expect(page.locator('#sim-incident')).toBeEnabled();
await deleteSimulation(redteamToken, sim.id);
});
test('AC-9.4 — soc visits pending/in_progress simulation: banner visible, SOC fields disabled', async ({
page,
context,
}) => {
// Test with pending status
const simPending = await createSimulation(redteamToken, engagementId, 'AC-9.4 pending');
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}/simulations/${simPending.id}/edit`);
// Banner must be visible
await expect(page.getByTestId('soc-blocked-banner')).toBeVisible();
await expect(
page.getByText(/simulation pas encore en revue/i),
).toBeVisible();
// SOC fields are disabled
await expect(page.locator('#sim-log-source')).toBeDisabled();
await expect(page.locator('#sim-soc-comment')).toBeDisabled();
// Test with in_progress status
const simIP = await createSimulation(redteamToken, engagementId, 'AC-9.4 in_progress');
const rtClient = makeClient(redteamToken);
await rtClient.patch(`/simulations/${simIP.id}`, { name: 'trigger' });
await page.goto(`/engagements/${engagementId}/simulations/${simIP.id}/edit`);
await expect(page.getByTestId('soc-blocked-banner')).toBeVisible();
await expect(page.locator('#sim-log-source')).toBeDisabled();
await deleteSimulation(redteamToken, simPending.id);
await deleteSimulation(redteamToken, simIP.id);
});
});