Files
mimic/e2e/tests/us27-instantiate-from-template.spec.ts

340 lines
13 KiB
TypeScript
Raw Normal View History

/**
* 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);
});
});