sprint/2-simulations #3
174
e2e/tests/us10-mitre-autocomplete.spec.ts
Normal file
174
e2e/tests/us10-mitre-autocomplete.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
245
e2e/tests/us11-workflow-transitions.spec.ts
Normal file
245
e2e/tests/us11-workflow-transitions.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
153
e2e/tests/us12-simulation-delete.spec.ts
Normal file
153
e2e/tests/us12-simulation-delete.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
192
e2e/tests/us7-simulation-create.spec.ts
Normal file
192
e2e/tests/us7-simulation-create.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
216
e2e/tests/us8-simulation-redteam-fill.spec.ts
Normal file
216
e2e/tests/us8-simulation-redteam-fill.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
200
e2e/tests/us9-soc-restricted-edit.spec.ts
Normal file
200
e2e/tests/us9-soc-restricted-edit.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user