test(e2e): sprint 5 acceptance tests — US-26 → US-28 + dropdown adaptations
Add three new spec files: - us26-templates-crud: API CRUD (AC-26.3–26.7) + UI list/form/delete/redirect (AC-26.8) - us27-instantiate-from-template: template_id copy + name override + 404 + decoupling (AC-27.1–27.3) + no auto-transition/engagement-activate (AC-27.4–27.5) + dropdown UI + picker modal + empty state + SOC gate (AC-27.6–27.7) - us28-templates-nav: Templates link admin+redteam only, SOC redirect, form editable (AC-28.1–28.3) Adapt sprint 2/3 e2e for sprint 5 dropdown: - us4-engagements: getByRole link "New simulation" → getByTestId "new-simulation-btn" - us7-simulation-create: same — split-button dropdown replaced the link Suite: 201 passed (1 pre-existing flaky in us3 re DB state, unrelated to sprint 5). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
339
e2e/tests/us27-instantiate-from-template.spec.ts
Normal file
339
e2e/tests/us27-instantiate-from-template.spec.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* US-27 — Redteam instantiates a template into an engagement.
|
||||
* Covers AC-27.1 → AC-27.7.
|
||||
* AC-27.3 (decoupling) tested via API: modifying instance ≠ modifying template and vice-versa.
|
||||
*/
|
||||
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 = 'us27-redteam';
|
||||
const SOC_USER = 'us27-soc';
|
||||
const PASS = 'us27-pass-strong';
|
||||
|
||||
interface Template { id: number; name: string; techniques: unknown[]; tactics: unknown[]; [k: string]: unknown; }
|
||||
interface Simulation { id: number; name: string; status: string; techniques: unknown[]; tactic_ids?: unknown[]; [k: string]: unknown; }
|
||||
|
||||
async function createTemplate(token: string, payload: Record<string, unknown>): Promise<Template> {
|
||||
const r = await makeClient(token).post('/templates', payload);
|
||||
if (r.status !== 201) throw new Error(`create template: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
return r.data as Template;
|
||||
}
|
||||
|
||||
async function deleteTemplate(token: string, id: number): Promise<void> {
|
||||
await makeClient(token).delete(`/templates/${id}`);
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, id: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${id}`);
|
||||
}
|
||||
|
||||
test.describe('US-27 — instantiate from template', () => {
|
||||
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-27 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-27.1 — POST with template_id copies all RT fields
|
||||
test('AC-27.1 — POST with template_id copies name, description, commands, prerequisites, techniques, tactic_ids', async () => {
|
||||
const tmpl = await createTemplate(redteamToken, {
|
||||
name: 'US27 full template',
|
||||
description: 'template desc',
|
||||
commands: 'cmd1\ncmd2',
|
||||
prerequisites: 'prereq',
|
||||
tactic_ids: ['TA0007'],
|
||||
});
|
||||
|
||||
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
|
||||
template_id: tmpl.id,
|
||||
});
|
||||
expect(r.status).toBe(201);
|
||||
const sim = r.data as Simulation;
|
||||
expect(sim.name).toBe('US27 full template');
|
||||
expect(sim.description).toBe('template desc');
|
||||
expect(sim.commands).toBe('cmd1\ncmd2');
|
||||
expect(sim.prerequisites).toBe('prereq');
|
||||
expect(sim.status).toBe('pending');
|
||||
expect(sim.executed_at).toBeNull();
|
||||
expect(Array.isArray(sim.tactics)).toBe(true);
|
||||
expect((sim.tactics as { id: string }[])[0]?.id).toBe('TA0007');
|
||||
|
||||
// SOC fields null
|
||||
expect(sim.soc_comment).toBeNull();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteTemplate(redteamToken, tmpl.id);
|
||||
});
|
||||
|
||||
// AC-27.2 — name override in body wins over template name
|
||||
test('AC-27.2 — POST with template_id + name override: body name wins', async () => {
|
||||
const tmpl = await createTemplate(redteamToken, { name: 'US27 override base' });
|
||||
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
|
||||
template_id: tmpl.id,
|
||||
name: 'Custom override name',
|
||||
});
|
||||
expect(r.status).toBe(201);
|
||||
expect(r.data.name).toBe('Custom override name');
|
||||
|
||||
await deleteSimulation(redteamToken, r.data.id as number);
|
||||
await deleteTemplate(redteamToken, tmpl.id);
|
||||
});
|
||||
|
||||
// AC-27.2 — POST without template_id keeps original blank-create behavior
|
||||
test('AC-27.2 — POST without template_id creates blank simulation (name required)', async () => {
|
||||
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
|
||||
name: 'Blank sim no template',
|
||||
});
|
||||
expect(r.status).toBe(201);
|
||||
expect(r.data.name).toBe('Blank sim no template');
|
||||
expect(r.data.techniques).toHaveLength(0);
|
||||
await deleteSimulation(redteamToken, r.data.id as number);
|
||||
});
|
||||
|
||||
// AC-27.1 — template_id inexistant → 404
|
||||
test('AC-27.1 — POST with unknown template_id → 404', async () => {
|
||||
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
|
||||
name: 'orphan',
|
||||
template_id: 999999,
|
||||
});
|
||||
expect(r.status).toBe(404);
|
||||
expect(r.data.error).toMatch(/template not found/i);
|
||||
});
|
||||
|
||||
// AC-27.3 — decoupling: modifying instance does not touch template
|
||||
test('AC-27.3 — modifying instance does NOT affect template', async () => {
|
||||
const tmpl = await createTemplate(redteamToken, {
|
||||
name: 'US27 decouple template',
|
||||
description: 'original desc',
|
||||
});
|
||||
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
|
||||
template_id: tmpl.id,
|
||||
});
|
||||
const sim = r.data as Simulation;
|
||||
|
||||
// Modify the instance
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { description: 'changed on instance' });
|
||||
|
||||
// Template unchanged
|
||||
const tCheck = await makeClient(redteamToken).get(`/templates/${tmpl.id}`);
|
||||
expect(tCheck.data.description).toBe('original desc');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteTemplate(redteamToken, tmpl.id);
|
||||
});
|
||||
|
||||
// AC-27.3 — decoupling: modifying template does not touch existing instance
|
||||
test('AC-27.3 — modifying template does NOT affect already-created instance', async () => {
|
||||
const tmpl = await createTemplate(redteamToken, {
|
||||
name: 'US27 decouple template 2',
|
||||
commands: 'original cmd',
|
||||
});
|
||||
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
|
||||
template_id: tmpl.id,
|
||||
});
|
||||
const sim = r.data as Simulation;
|
||||
|
||||
// Modify the template
|
||||
await makeClient(redteamToken).patch(`/templates/${tmpl.id}`, { commands: 'modified cmd' });
|
||||
|
||||
// Instance unchanged
|
||||
const sCheck = await makeClient(redteamToken).get(`/simulations/${sim.id}`);
|
||||
expect(sCheck.data.commands).toBe('original cmd');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteTemplate(redteamToken, tmpl.id);
|
||||
});
|
||||
|
||||
// AC-27.4 — auto-transition NOT triggered on creation from template
|
||||
test('AC-27.4 — creation from template with techniques does NOT auto-transition (stays pending)', async () => {
|
||||
const tmpl = await createTemplate(redteamToken, {
|
||||
name: 'US27 no auto-transition',
|
||||
tactic_ids: ['TA0007'],
|
||||
});
|
||||
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
|
||||
template_id: tmpl.id,
|
||||
});
|
||||
expect(r.status).toBe(201);
|
||||
expect(r.data.status).toBe('pending');
|
||||
|
||||
await deleteSimulation(redteamToken, r.data.id as number);
|
||||
await deleteTemplate(redteamToken, tmpl.id);
|
||||
});
|
||||
|
||||
// AC-27.5 — engagement auto-status NOT triggered by instantiation
|
||||
test('AC-27.5 — engagement stays planned after instantiation from template', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US27 auto-status check',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
const tmpl = await createTemplate(redteamToken, {
|
||||
name: 'US27 auto-status template',
|
||||
tactic_ids: ['TA0001'],
|
||||
});
|
||||
const r = await makeClient(redteamToken).post(`/engagements/${eng.id}/simulations`, {
|
||||
template_id: tmpl.id,
|
||||
});
|
||||
expect(r.status).toBe(201);
|
||||
|
||||
// Engagement status must still be planned
|
||||
const engCheck = await makeClient(redteamToken).get(`/engagements/${eng.id}`);
|
||||
expect(engCheck.data.status).toBe('planned');
|
||||
|
||||
await deleteSimulation(redteamToken, r.data.id as number);
|
||||
await deleteTemplate(redteamToken, tmpl.id);
|
||||
const tok = await adminToken();
|
||||
await deleteEngagement(tok, eng.id);
|
||||
});
|
||||
|
||||
// AC-27.6 — UI: dropdown + TemplatePickerModal
|
||||
test('AC-27.6 — EngagementDetailPage: dropdown toggle shows Blank + From template… options', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}`);
|
||||
|
||||
// Main "New" button visible (direct Blank action)
|
||||
const newBtn = page.getByTestId('new-simulation-btn');
|
||||
await expect(newBtn).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Chevron toggle opens dropdown
|
||||
await page.getByTestId('new-simulation-dropdown-toggle').click();
|
||||
await expect(page.getByRole('menuitem', { name: /blank/i })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: /from template/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('AC-27.6 — clicking Blank navigates to /engagements/:eid/simulations/new', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}`);
|
||||
|
||||
await page.getByTestId('new-simulation-btn').click();
|
||||
await expect(page).toHaveURL(/\/engagements\/\d+\/simulations\/new/);
|
||||
});
|
||||
|
||||
test('AC-27.6 — "From template…" opens TemplatePickerModal', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}`);
|
||||
|
||||
await page.getByTestId('new-simulation-dropdown-toggle').click();
|
||||
await page.getByTestId('from-template-btn').click();
|
||||
|
||||
// Modal appears
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await expect(dialog).toContainText(/from template/i);
|
||||
});
|
||||
|
||||
test('AC-27.6 — TemplatePickerModal empty state when no templates exist', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Delete ALL templates (cross-suite leftovers included) to get a clean empty state
|
||||
const tok = await adminToken();
|
||||
const allTmpl = await makeClient(tok).get('/templates');
|
||||
for (const t of allTmpl.data as Template[]) {
|
||||
await deleteTemplate(tok, t.id);
|
||||
}
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}`);
|
||||
|
||||
await page.getByTestId('new-simulation-dropdown-toggle').click();
|
||||
await page.getByTestId('from-template-btn').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await expect(dialog).toContainText(/no templates available/i);
|
||||
});
|
||||
|
||||
test('AC-27.6 — selecting template from picker creates simulation and navigates to edit page', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const tmpl = await createTemplate(redteamToken, { name: 'US27 picker select template' });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}`);
|
||||
|
||||
await page.getByTestId('new-simulation-dropdown-toggle').click();
|
||||
await page.getByTestId('from-template-btn').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Click the template row
|
||||
await dialog.getByTestId(`template-row-${tmpl.id}`).click();
|
||||
|
||||
// Modal closes, redirect to /engagements/:eid/simulations/:sid/edit
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/engagements/${engagementId}/simulations/\\d+/edit`),
|
||||
{ timeout: 8_000 },
|
||||
);
|
||||
|
||||
// Simulation name matches template name (use heading to avoid strict mode with toast)
|
||||
await expect(page.getByRole('heading', { name: 'US27 picker select template' })).toBeVisible();
|
||||
|
||||
// Clean up: extract sim id from URL
|
||||
const url = page.url();
|
||||
const m = url.match(/\/simulations\/(\d+)\/edit/);
|
||||
if (m) await deleteSimulation(redteamToken, parseInt(m[1]));
|
||||
await deleteTemplate(redteamToken, tmpl.id);
|
||||
});
|
||||
|
||||
// AC-27.7 — SOC has no access to the new simulation button
|
||||
test('AC-27.7 — SOC does not see the New simulation dropdown on EngagementDetailPage', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Advance a sim to review_required so SOC can access the engagement page
|
||||
const sim = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
|
||||
name: 'US27 soc visibility sim',
|
||||
});
|
||||
const simId = sim.data.id as number;
|
||||
await makeClient(redteamToken).patch(`/simulations/${simId}`, { name: 'US27 soc vis' });
|
||||
await makeClient(redteamToken).post(`/simulations/${simId}/transition`, { to: 'review_required' });
|
||||
|
||||
await seedTokenInStorage(context, socToken);
|
||||
await page.goto(`/engagements/${engagementId}`);
|
||||
|
||||
// No new-simulation button for SOC
|
||||
await expect(page.getByTestId('new-simulation-btn')).not.toBeVisible();
|
||||
await expect(page.getByTestId('new-simulation-dropdown-toggle')).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, simId);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user