2026-05-28 07:15:04 +02:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
});
|
2026-05-28 07:23:33 +02:00
|
|
|
|
|
|
|
|
// NIT 1 — Dropdown closes on Escape key and on outside click
|
|
|
|
|
test('NIT-1 — dropdown closes on Escape key press', async ({
|
|
|
|
|
page,
|
|
|
|
|
context,
|
|
|
|
|
}) => {
|
|
|
|
|
await seedTokenInStorage(context, redteamToken);
|
|
|
|
|
await page.goto(`/engagements/${engagementId}`);
|
|
|
|
|
|
|
|
|
|
await page.getByTestId('new-simulation-dropdown-toggle').click();
|
|
|
|
|
// Menu is open
|
|
|
|
|
await expect(page.getByTestId('from-template-btn')).toBeVisible({ timeout: 3_000 });
|
|
|
|
|
|
|
|
|
|
// Press Escape
|
|
|
|
|
await page.keyboard.press('Escape');
|
|
|
|
|
await expect(page.getByTestId('from-template-btn')).not.toBeVisible({ timeout: 3_000 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('NIT-1 — dropdown closes when clicking outside', async ({
|
|
|
|
|
page,
|
|
|
|
|
context,
|
|
|
|
|
}) => {
|
|
|
|
|
await seedTokenInStorage(context, redteamToken);
|
|
|
|
|
await page.goto(`/engagements/${engagementId}`);
|
|
|
|
|
|
|
|
|
|
await page.getByTestId('new-simulation-dropdown-toggle').click();
|
|
|
|
|
await expect(page.getByTestId('from-template-btn')).toBeVisible({ timeout: 3_000 });
|
|
|
|
|
|
|
|
|
|
// Click somewhere outside the dropdown (page heading)
|
|
|
|
|
await page.getByRole('heading').first().click({ force: true });
|
|
|
|
|
await expect(page.getByTestId('from-template-btn')).not.toBeVisible({ timeout: 3_000 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// NIT 2 — Empty-engagement SimulationList still shows dropdown
|
|
|
|
|
test('NIT-2 — engagement with 0 simulations still shows New simulation dropdown', async ({
|
|
|
|
|
page,
|
|
|
|
|
context,
|
|
|
|
|
}) => {
|
|
|
|
|
// Create a fresh engagement with no simulations
|
|
|
|
|
const eng = await createEngagement(redteamToken, {
|
|
|
|
|
name: 'US27 empty eng dropdown',
|
|
|
|
|
start_date: '2026-01-01',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await seedTokenInStorage(context, redteamToken);
|
|
|
|
|
await page.goto(`/engagements/${eng.id}`);
|
|
|
|
|
|
|
|
|
|
// Primary button visible even in empty state
|
|
|
|
|
await expect(page.getByTestId('new-simulation-btn')).toBeVisible({ timeout: 5_000 });
|
|
|
|
|
|
|
|
|
|
// Chevron also visible and functional
|
|
|
|
|
await page.getByTestId('new-simulation-dropdown-toggle').click();
|
|
|
|
|
await expect(page.getByTestId('from-template-btn')).toBeVisible({ timeout: 3_000 });
|
|
|
|
|
|
|
|
|
|
const tok = await adminToken();
|
|
|
|
|
await deleteEngagement(tok, eng.id);
|
|
|
|
|
});
|
2026-05-28 07:15:04 +02:00
|
|
|
});
|