test(e2e): sprint 3 acceptance tests — US-13 to US-16
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>
This commit is contained in:
282
e2e/tests/us16-regression-sprint2.spec.ts
Normal file
282
e2e/tests/us16-regression-sprint2.spec.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* US-16 — regression: sprint 2 features still work under the sprint 3 model.
|
||||
* Covers AC-16.1 → AC-16.3.
|
||||
*
|
||||
* This file re-exercises critical sprint 2 ACs that are most likely to break
|
||||
* due to the scalar→array MITRE migration:
|
||||
* - Auto-transition pending→in_progress (AC-8.2 / AC-13.5)
|
||||
* - Manual workflow transitions + badge update (AC-11.x)
|
||||
* - SOC field-level RBAC (AC-9.x)
|
||||
* - MitreTechniquePicker still accessible via Quick Search (AC-16.2)
|
||||
*/
|
||||
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 = 'us16-redteam';
|
||||
const SOC_USER = 'us16-soc';
|
||||
const PASS = 'us16-pass-strong';
|
||||
|
||||
interface Simulation {
|
||||
id: number;
|
||||
status: string;
|
||||
techniques: { id: string; name: string; tactics: string[] }[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function createSimulation(
|
||||
token: string,
|
||||
engagementId: number,
|
||||
name = 'US-16 sim',
|
||||
): Promise<Simulation> {
|
||||
const client = makeClient(token);
|
||||
const r = await client.post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
test.describe('US-16 — sprint 2 regression', () => {
|
||||
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-16 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 */
|
||||
}
|
||||
});
|
||||
|
||||
// AC-16.1 — workflow sprint 2: auto-transition, manual transitions, SOC RBAC
|
||||
|
||||
test('AC-16.1 — auto-transition pending→in_progress triggered by PATCH with non-empty technique_ids', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 auto-transition');
|
||||
expect(sim.status).toBe('pending');
|
||||
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
technique_ids: ['T1059'],
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('in_progress');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-16.1 — auto-transition triggered by non-technique redteam PATCH (name)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 auto-name');
|
||||
expect(sim.status).toBe('pending');
|
||||
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
name: 'trigger by name',
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('in_progress');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-16.1 — manual transition in_progress→review_required→closed', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 workflow');
|
||||
const rtClient = makeClient(redteamToken);
|
||||
|
||||
// Trigger in_progress
|
||||
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 workflow' });
|
||||
|
||||
// in_progress → review_required
|
||||
const r1 = await rtClient.post(`/simulations/${sim.id}/transition`, {
|
||||
to: 'review_required',
|
||||
});
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.data.status).toBe('review_required');
|
||||
|
||||
// review_required → done
|
||||
const r2 = await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'done' });
|
||||
expect(r2.status).toBe(200);
|
||||
expect(r2.data.status).toBe('done');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-16.1 — SOC cannot PATCH technique_ids (403)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc block');
|
||||
const rtClient = makeClient(redteamToken);
|
||||
|
||||
// Advance to review_required so SOC has access
|
||||
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 soc block' });
|
||||
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
|
||||
const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, {
|
||||
technique_ids: ['T1059'],
|
||||
});
|
||||
expect(r.status).toBe(403);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-16.1 — SOC can PATCH soc_comment without affecting status', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc comment');
|
||||
const rtClient = makeClient(redteamToken);
|
||||
|
||||
// Advance to review_required
|
||||
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 soc comment' });
|
||||
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
|
||||
const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, {
|
||||
soc_comment: 'Looks good, close it.',
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('review_required');
|
||||
expect(r.data.soc_comment).toBe('Looks good, close it.');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-16.1 — SOC cannot transition pending simulation', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc transition');
|
||||
|
||||
const r = await makeClient(socToken).post(`/simulations/${sim.id}/transition`, {
|
||||
to: 'review_required',
|
||||
});
|
||||
expect(r.status).toBe(403);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-16.1 — workflow badge updates in UI without page reload', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 badge');
|
||||
const rtClient = makeClient(redteamToken);
|
||||
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 badge' });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
const badge = page.getByTestId('simulation-status-badge');
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toHaveAttribute('data-status', 'in_progress');
|
||||
|
||||
// Trigger transition via button
|
||||
await page.getByRole('button', { name: /mark for review/i }).click();
|
||||
await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 });
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-16.2 — MitreTechniquePicker still accessible via Quick Search (clean rewrite onSelect)
|
||||
|
||||
test('AC-16.2 — MitreTechniquePicker accessible via Quick Search, appends tag on selection', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.2 picker');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Quick Search button reveals the picker
|
||||
await page.getByRole('button', { name: /quick search/i }).click();
|
||||
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||
await expect(picker).toBeVisible();
|
||||
|
||||
// Type to search
|
||||
await picker.fill('T1078');
|
||||
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
|
||||
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Keyboard select
|
||||
await picker.press('ArrowDown');
|
||||
await picker.press('Enter');
|
||||
|
||||
// Tag appears (onSelect one-shot mode — appends to list)
|
||||
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1078', {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Auto-save toast
|
||||
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-16.3 — no sprint 1/2 e2e broken: spot-check key assertions with new model
|
||||
|
||||
test('AC-16.3 — simulation serialisation has techniques array (not scalar MITRE fields)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.3 schema');
|
||||
|
||||
// New schema: techniques array
|
||||
expect(Array.isArray(sim.techniques)).toBe(true);
|
||||
expect(sim).not.toHaveProperty('mitre_technique_id');
|
||||
expect(sim).not.toHaveProperty('mitre_technique_name');
|
||||
|
||||
// PATCH technique_ids → techniques array in response
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
technique_ids: ['T1059', 'T1078'],
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(Array.isArray(r.data.techniques)).toBe(true);
|
||||
expect(r.data.techniques).toHaveLength(2);
|
||||
expect(r.data.techniques[0].id).toBe('T1059');
|
||||
expect(r.data.techniques[0].name).toBeTruthy();
|
||||
expect(Array.isArray(r.data.techniques[0].tactics)).toBe(true);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-16.3 — edit form Red Team section still has name, description, commands fields', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.3 form fields');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Red Team section
|
||||
await expect(page.getByRole('heading', { name: /red team/i })).toBeVisible();
|
||||
|
||||
// Core fields still present
|
||||
await expect(page.locator('#sim-name')).toBeVisible();
|
||||
await expect(page.locator('#sim-description')).toBeVisible();
|
||||
await expect(page.locator('#sim-commands')).toBeVisible();
|
||||
|
||||
// Save Red Team button still present
|
||||
await expect(page.getByRole('button', { name: /save red team/i })).toBeVisible();
|
||||
|
||||
// MitreTechniquesField buttons present
|
||||
await expect(page.getByRole('button', { name: /add technique/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /quick search/i })).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user