Compare commits
10 Commits
f5ea9d16af
...
6d2bb091e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d2bb091e2 | ||
|
|
43ab7073f1 | ||
|
|
7d81ce9785 | ||
|
|
a824df06b2 | ||
|
|
5aa839d105 | ||
|
|
e99286ef8e | ||
|
|
988de841e5 | ||
|
|
fc530af78b | ||
|
|
9964d058f4 | ||
|
|
892692f3b8 |
52
CHANGELOG.md
52
CHANGELOG.md
@@ -6,6 +6,58 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added — Sprint 4 (UI polish + workflow tightening + dark mode + process hygiene)
|
||||
|
||||
**Backend** (193 pytest passing — 192 sprint-1-to-3 + 1 sprint-4)
|
||||
- `Simulation.tactic_ids` JSON column (default `[]`, NOT NULL via `server_default`). Sprint 3's `techniques` array is joined by a parallel `tactics` field in the serialized response.
|
||||
- Alembic migration `0004_simulation_tactic_ids.py` — simple ADD COLUMN (SQLite native); downgrade via `batch_alter_table`.
|
||||
- `PATCH /api/simulations/<sid>` accepts `{tactic_ids: ["TA0007", ...]}` (TA-format, validated against the hardcoded `_TACTIC_IDS` map — no MITRE bundle dependency for tactics since TA-ids are a stable MITRE standard). Dedup via `dict.fromkeys`. SOC sending `tactic_ids` → 403. Auto-transition `pending → in_progress` extended to non-empty `tactic_ids`.
|
||||
- **Done is now terminal**: `PATCH /api/simulations/<sid>` on a `done` simulation → **409** `{error: "simulation is done — reopen first"}` (applies to all 3 roles, prioritised over RBAC field-level).
|
||||
- **Reopen transition**: `POST /api/simulations/<sid>/transition {to: "review_required"}` from `done` → 200, open to **admin + redteam + soc**. Implemented as a special case before the `_ALLOWED_TRANSITIONS` dict lookup; other transitions from `done` (`→ pending` / `→ in_progress` / `→ done`) remain forbidden (409 via dict miss).
|
||||
- **Engagement auto-status**: when any simulation transitions to `in_progress` (auto or manual), if `engagement.status == planned` → engagement passes to `active` in the same DB transaction. No auto-rollback. The `_maybe_activate_engagement` helper modifies and `db.session.add()`s only — the caller commits (no double-commit).
|
||||
- `GET /api/mitre/matrix` `tactic_id` field now returned in TA-format (`"TA0007"`) instead of the internal slug (`"discovery"`). Aligns with the PATCH endpoint contract — frontend can round-trip the same `tactic_id` between matrix display and PATCH body. Spec-drift caught by the e2e test-verifier (AC-21.6 defect).
|
||||
- Internal helpers : `_TACTIC_IDS` (TA-id → short-name, 12 entries, non-sequential), `_SLUG_TO_TA_ID` (reverse), `lookup_tactic()`, `get_tactic_name()`.
|
||||
- Migration tests now derive paths from `__file__` instead of hardcoded worktree absolute paths (recurring sprint-3 issue resolved).
|
||||
|
||||
**Frontend** (92 vitest passing — typecheck + lint clean)
|
||||
- **Dark mode**: full Tailwind `darkMode: 'class'` plumbing, themed surface tokens via CSS variables under `:root` / `.dark` in `index.css`. Three-state cycle (`light` / `dark` / `system`) toggle in the topbar with lucide-react icons (Sun / Moon / Monitor). Persisted under localStorage key `mimic-theme` (default `system`, follows `prefers-color-scheme`). Dedicated `useTheme()` hook orchestrates the cycle + media-query listener.
|
||||
- **Slab token split**: a new `slab` / `slab-text` / `slab-muted` token family stays fixed `#111827` / `#f9fafb` / `#6b7280` regardless of theme. Used for permanently-dark surfaces (utility strip, footer, modal backdrop) that must NOT invert in dark mode. The themed `ink` token is now strictly for text. `.btn-ink` uses `@apply bg-slab` (single source of truth).
|
||||
- **Modal backdrop**: new `.modal-backdrop` CSS class (fixed `rgba(0,0,0,0.6)`) replaces `bg-ink/60` (which inverted in dark mode). Applies to `MitreMatrixModal` and `ConfirmDialog`.
|
||||
- **Badge contrast in dark mode**: `SimulationStatusBadge` and `StatusBadge` use `text-white` (fixed) on colored backgrounds instead of `text-canvas` / `text-ink-on` (which inverted). `Toast` error uses `bg-slab text-slab-text`.
|
||||
- **Dark mode shadows**: new `soft-lift-dark` and `floating-dark` token variants, applied to cards and modals via `dark:shadow-*` so the lift remains visible on dark canvas. Hairlines bumped (`#4b5563`) for better separator visibility.
|
||||
- **MITRE matrix modal overhaul**: 12-column CSS grid (`repeat(12, minmax(0, 1fr))`), no horizontal scroll at 1280×720. Compact technique cells (`text-[12px]`, hairline borders). Sticky tactic headers (uppercase, count badge). Sub-techniques expand/collapse preserved from sprint 3.
|
||||
- **Tactic selection in matrix**: clicking a tactic header toggles its selection in addition to techniques + sub-techniques. Tactic chips render with `bg-primary text-canvas` (filled), distinct from technique chips (`bg-primary-soft text-primary-deep`). Apply emits one combined PATCH `{technique_ids, tactic_ids}` — no two sequential calls.
|
||||
- **MITRE input redesign**: replaces the prior `Add technique` + `Quick search` button pair with an inline autocomplete input + matrix icon button to the right. Chips display the reference only (`T1059.001` or `TA0007`); full technique name surfaces on `title=` hover. Empty state minimal.
|
||||
- **`done` simulation UI**: form fields are fully disabled, `MitreTechniquesField` is read-only (chips without ×, input + matrix icon hidden), action bar shows ONLY a `Reopen` button (visible to all 3 roles per RBAC). Save / Mark for review / Close / Delete are hidden in the done state. A "this simulation is done and read-only" banner replaces them.
|
||||
- **UsersAdminPage `Create account` form alignment** (3rd attempt — finally pixel-perfect): refactored from `FormField` + `items-end` to an explicit 3-row grid (labels / inputs+button / hints) using `grid-rows-[auto_auto_auto]`. Labels share row 1, inputs + button share row 2, hint sits alone in row 3 — the browser cannot misalign cells of different heights.
|
||||
- **EngagementsListPage dedup**: single `+ New` CTA (header + empty-state share the same label).
|
||||
- **Engagement query invalidation**: `useUpdateSimulation` and `useTransitionSimulation` now invalidate both the simulation queries AND `["engagement", engagement_id]` + `["engagements"]` so the engagement status badge updates without a full reload after the auto-transition.
|
||||
|
||||
**Process hygiene** (US-24)
|
||||
- New agent definition `.claude/agents/design-reviewer.md` — read-only, runs AFTER `frontend-builder` and BEFORE `code-reviewer`. Audits alignment, DESIGN.md token usage, light/dark consistency, typography, whitespace rhythm, responsive sanity at 1280×720, button convention, V1 a11y, and inter-screen coherence.
|
||||
- Updated `.claude/agents/frontend-builder.md` Definition of Done — screenshots are now MANDATORY (one per feature/state introduced or modified, light + dark when theming is in scope). A "Dev server not started" line is a hard block.
|
||||
|
||||
**Infra hygiene** (US-25)
|
||||
- `scripts/open-pr.sh` — wraps `POST /api/v1/repos/{owner}/{repo}/pulls` on the Gitea REST API. Reads credentials from `~/.git-credentials` (same source as `git push` — no token in env). Detects host/owner/repo from `git remote get-url origin`. Validates args, prints PR URL.
|
||||
- New Makefile target `open-pr TITLE="..." BODY=path/to/body.md [BASE=main]` wrapping the script. Team-lead PR creation is now automated.
|
||||
- README `Make targets` table documents the new target.
|
||||
|
||||
**Acceptance tests** (Playwright, **158 passed**)
|
||||
- 7 new spec files (one per testable US): `us17-ui-polish`, `us18-done-readonly-reopen`, `us19-engagement-auto-status`, `us20-matrix-fits-modal`, `us21-tactic-selection`, `us22-mitre-input-redesign`, `us23-dark-mode`.
|
||||
- Coverage gaps from code-reviewer filled: `+N` suffix when techniques + tactics are mixed in the `SimulationList` MITRE column ; Tab focus-trap cycle in `MitreMatrixModal` ; dark-mode `localStorage` persistence across reload.
|
||||
- AC-21.6 defect caught by the e2e (matrix returned slug `tactic_id`, PATCH expected TA-format) was bounced to backend-builder and resolved within the sprint.
|
||||
|
||||
### Changed
|
||||
- 2026-05-27 — SPEC.md § Fonctionnement clarified: `done` is terminal, only Reopen (open to all 3 roles) returns to `review_required`. Engagement auto-flips `planned → active` on first simulation `in_progress`, never the reverse.
|
||||
- 2026-05-27 — SPEC.md § Référentiel MITRE: added the sprint 4 `tactic_ids` (separated from `technique_ids`).
|
||||
- 2026-05-27 — SPEC.md § UI/UX (new section): theming (light/dark/system, default = `system`), button convention (icon + ≤8-char label), modal focus trap V1.
|
||||
- 2026-05-27 — SPEC.md § Workflows: `design-reviewer` agent inserted between `frontend-builder` and `code-reviewer`. PR creation now via `make open-pr`.
|
||||
- 2026-05-27 — Carry-over commit: sprint 3 `§ Simulation` multi-techniques edit had been left uncommitted at sprint 3 close; applied at sprint 4 start so SPEC.md and the shipped code finally agree (lesson logged in tasks/lessons.md).
|
||||
|
||||
---
|
||||
|
||||
## [Sprint 3] — Multi-technique simulations + MITRE matrix modal (merged 2026-05-27)
|
||||
|
||||
### Added — Sprint 3 (Multi-technique simulations + MITRE matrix modal)
|
||||
|
||||
**Backend** (164 pytest passing)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs.
|
||||
|
||||
> Status: **Sprint 3 — Multi-technique simulations + MITRE matrix modal**. A simulation can now be tagged with multiple MITRE techniques (top-level and sub-techniques) via either autocomplete or a clickable ATT&CK matrix modal. Tags auto-save on add/remove; the rest of the Sprint 2 Purple Team workflow (workflow states, RBAC, etc.) is unchanged.
|
||||
> Status: **Sprint 4 — UI polish + workflow tightening + dark mode + process hygiene**. The Purple Team workflow is now tighter (Done is terminal, Reopen returns to Review required, engagements auto-flip Planned → Active on first in-progress simulation), simulations can be tagged with both techniques AND tactics (TA-ids), the MITRE matrix modal fits the viewport without horizontal scroll, the app supports light / dark / system theming, and PR creation is one Make target away.
|
||||
|
||||
---
|
||||
|
||||
@@ -139,9 +139,9 @@ npm run dev # http://localhost:5173 with /api proxied to :5000
|
||||
Tests:
|
||||
|
||||
```bash
|
||||
cd backend && pytest -q # 164 tests
|
||||
cd frontend && npm run test -- --run # 86 tests
|
||||
cd e2e && npx playwright test # 105 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6)
|
||||
cd backend && pytest -q # 193 tests
|
||||
cd frontend && npm run test -- --run # 92 tests
|
||||
cd e2e && npx playwright test # 158 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -42,6 +42,9 @@ _TACTIC_IDS: dict[str, str] = {
|
||||
"TA0040": "impact",
|
||||
}
|
||||
|
||||
# Reverse: slug → TA-id (derived from _TACTIC_IDS, used by _build_matrix).
|
||||
_SLUG_TO_TA_ID: dict[str, str] = {v: k for k, v in _TACTIC_IDS.items()}
|
||||
|
||||
TACTIC_NAMES: dict[str, str] = {
|
||||
"initial-access": "Initial Access",
|
||||
"execution": "Execution",
|
||||
@@ -114,14 +117,16 @@ def _build_matrix(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
subs.sort(key=lambda x: x["name"])
|
||||
|
||||
matrix: list[dict[str, Any]] = []
|
||||
for tactic_id in _TACTIC_ORDER:
|
||||
techs = tactic_techs.get(tactic_id, [])
|
||||
for slug in _TACTIC_ORDER:
|
||||
techs = tactic_techs.get(slug, [])
|
||||
# Sort techniques alphabetically.
|
||||
techs_sorted = sorted(techs, key=lambda x: x["name"])
|
||||
tactic_name = TACTIC_NAMES.get(tactic_id, tactic_id.replace("-", " ").title())
|
||||
tactic_name = TACTIC_NAMES.get(slug, slug.replace("-", " ").title())
|
||||
# Expose TA-id so the frontend can send tactic_ids back in PATCH unchanged.
|
||||
ta_id = _SLUG_TO_TA_ID.get(slug, slug)
|
||||
matrix.append(
|
||||
{
|
||||
"tactic_id": tactic_id,
|
||||
"tactic_id": ta_id,
|
||||
"tactic_name": tactic_name,
|
||||
"techniques": [
|
||||
{
|
||||
|
||||
@@ -225,10 +225,5 @@ def transition(
|
||||
|
||||
simulation.status = SimulationStatus(to_status)
|
||||
simulation.updated_at = datetime.now(UTC)
|
||||
|
||||
# Hook: auto-activate engagement when simulation enters in_progress via manual transition.
|
||||
if simulation.status == SimulationStatus.IN_PROGRESS:
|
||||
_maybe_activate_engagement(simulation)
|
||||
|
||||
db.session.commit()
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Sprint 4 — engagement auto-status planned→active (AC-19)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
from backend.tests.conftest import auth_headers as _h
|
||||
@@ -156,10 +158,11 @@ def test_migration_0004_tactic_ids_not_null_after_upgrade() -> None:
|
||||
import alembic.op as _op_module
|
||||
_op_module._proxy = ops # type: ignore[attr-defined]
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"mig_0004",
|
||||
"/home/user/Documents/01_Projects/mimic/.claude/worktrees/sprint-4-ui-polish/backend/migrations/versions/0004_simulation_tactic_ids.py",
|
||||
_mig_path = (
|
||||
pathlib.Path(__file__).parent.parent
|
||||
/ "migrations" / "versions" / "0004_simulation_tactic_ids.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("mig_0004", _mig_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mig = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mig) # type: ignore[union-attr]
|
||||
|
||||
@@ -305,14 +305,14 @@ def test_get_matrix_returns_ordered_tactics(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
matrix = mitre_svc.get_matrix()
|
||||
tactic_ids = [t["tactic_id"] for t in matrix]
|
||||
# initial-access must come before execution in canonical order.
|
||||
assert tactic_ids.index("initial-access") < tactic_ids.index("execution")
|
||||
# TA0001 (initial-access) must come before TA0002 (execution) in canonical order.
|
||||
assert tactic_ids.index("TA0001") < tactic_ids.index("TA0002")
|
||||
|
||||
|
||||
def test_get_matrix_subtechniques_nested(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
matrix = mitre_svc.get_matrix()
|
||||
exec_tactic = next(t for t in matrix if t["tactic_id"] == "execution")
|
||||
exec_tactic = next(t for t in matrix if t["tactic_id"] == "TA0002")
|
||||
t1059 = next((t for t in exec_tactic["techniques"] if t["id"] == "T1059"), None)
|
||||
assert t1059 is not None
|
||||
sub_ids = [s["id"] for s in t1059["subtechniques"]]
|
||||
@@ -323,7 +323,7 @@ def test_get_matrix_subtechniques_nested(bundle_file: pathlib.Path) -> None:
|
||||
def test_get_matrix_subtechniques_sorted_by_name(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
matrix = mitre_svc.get_matrix()
|
||||
exec_tactic = next(t for t in matrix if t["tactic_id"] == "execution")
|
||||
exec_tactic = next(t for t in matrix if t["tactic_id"] == "TA0002")
|
||||
t1059 = next(t for t in exec_tactic["techniques"] if t["id"] == "T1059")
|
||||
names = [s["name"] for s in t1059["subtechniques"]]
|
||||
assert names == sorted(names)
|
||||
@@ -332,7 +332,7 @@ def test_get_matrix_subtechniques_sorted_by_name(bundle_file: pathlib.Path) -> N
|
||||
def test_get_matrix_techniques_sorted_by_name(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
matrix = mitre_svc.get_matrix()
|
||||
ia_tactic = next(t for t in matrix if t["tactic_id"] == "initial-access")
|
||||
ia_tactic = next(t for t in matrix if t["tactic_id"] == "TA0001")
|
||||
names = [t["name"] for t in ia_tactic["techniques"]]
|
||||
assert names == sorted(names)
|
||||
|
||||
@@ -340,7 +340,7 @@ def test_get_matrix_techniques_sorted_by_name(bundle_file: pathlib.Path) -> None
|
||||
def test_get_matrix_technique_no_subtechniques(bundle_file: pathlib.Path) -> None:
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
matrix = mitre_svc.get_matrix()
|
||||
ia_tactic = next(t for t in matrix if t["tactic_id"] == "initial-access")
|
||||
ia_tactic = next(t for t in matrix if t["tactic_id"] == "TA0001")
|
||||
phishing = next((t for t in ia_tactic["techniques"] if t["id"] == "T1566"), None)
|
||||
assert phishing is not None
|
||||
assert phishing["subtechniques"] == []
|
||||
@@ -355,8 +355,8 @@ def test_matrix_endpoint_ok(
|
||||
data = resp.get_json()
|
||||
assert isinstance(data, list)
|
||||
tactic_ids = [t["tactic_id"] for t in data]
|
||||
assert "initial-access" in tactic_ids
|
||||
assert "execution" in tactic_ids
|
||||
assert "TA0001" in tactic_ids # initial-access
|
||||
assert "TA0002" in tactic_ids # execution
|
||||
|
||||
|
||||
def test_matrix_endpoint_503_when_not_loaded(
|
||||
@@ -389,6 +389,15 @@ def test_get_matrix_command_and_control_display_name(bundle_file: pathlib.Path)
|
||||
"""MITRE official name uses lowercase 'and' — not title-cased."""
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
matrix = mitre_svc.get_matrix()
|
||||
c2 = next((t for t in matrix if t["tactic_id"] == "command-and-control"), None)
|
||||
c2 = next((t for t in matrix if t["tactic_id"] == "TA0011"), None)
|
||||
assert c2 is not None
|
||||
assert c2["tactic_name"] == "Command and Control"
|
||||
|
||||
|
||||
def test_get_matrix_tactic_id_is_ta_format(bundle_file: pathlib.Path) -> None:
|
||||
"""Matrix tactic_id must use TA-format so frontend can send it back in PATCH tactic_ids."""
|
||||
mitre_svc.load_bundle(bundle_file)
|
||||
matrix = mitre_svc.get_matrix()
|
||||
for entry in matrix:
|
||||
tid = entry["tactic_id"]
|
||||
assert tid.startswith("TA"), f"tactic_id {tid!r} must be TA-format, not a slug"
|
||||
|
||||
@@ -405,10 +405,11 @@ def test_migration_0003_techniques_not_null_after_upgrade() -> None:
|
||||
import alembic.op as _op_module
|
||||
_op_module._proxy = ops # type: ignore[attr-defined]
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"mig_0003",
|
||||
"/home/user/Documents/01_Projects/mimic/.claude/worktrees/sprint-4-ui-polish/backend/migrations/versions/0003_simulation_techniques_array.py",
|
||||
_mig_path = (
|
||||
pathlib.Path(__file__).parent.parent
|
||||
/ "migrations" / "versions" / "0003_simulation_techniques_array.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("mig_0003", _mig_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mig = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mig) # type: ignore[union-attr]
|
||||
|
||||
@@ -137,8 +137,8 @@ test.describe('US-10 — MITRE autocomplete', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search"
|
||||
await page.getByRole('button', { name: /quick search/i }).click();
|
||||
// Sprint 4: picker opens by clicking the inline placeholder text
|
||||
await page.getByText(/search technique/i).click();
|
||||
|
||||
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||
await expect(picker).toBeVisible();
|
||||
@@ -159,14 +159,14 @@ test.describe('US-10 — MITRE autocomplete', () => {
|
||||
await picker.press('ArrowDown');
|
||||
await picker.press('Enter');
|
||||
|
||||
// Sprint 3: after selection the picker resets (one-shot append mode).
|
||||
// After selection the picker resets (one-shot append mode).
|
||||
// The tag T1059 should appear in the techniques field.
|
||||
await expect(listbox).not.toBeVisible();
|
||||
await expect(page.getByTestId('techniques-tag-list')).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059');
|
||||
|
||||
// Escape closes the dropdown (re-open picker to test Escape)
|
||||
await page.getByRole('button', { name: /quick search/i }).click();
|
||||
// Escape closes the dropdown (re-open picker via inline placeholder)
|
||||
await page.getByText(/search technique/i).click();
|
||||
await picker.fill('T1');
|
||||
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
||||
await picker.press('Escape');
|
||||
|
||||
@@ -127,11 +127,12 @@ test.describe('US-11 — workflow transitions', () => {
|
||||
expect(rSOC.status).toBe(200);
|
||||
expect(rSOC.data.status).toBe('done');
|
||||
|
||||
// done → review_required is invalid (409)
|
||||
// Sprint 4: done → review_required is the Reopen flow, now valid (200).
|
||||
const rBack = await rtClient.post(`/simulations/${simRT.id}/transition`, {
|
||||
to: 'review_required',
|
||||
});
|
||||
expect(rBack.status).toBe(409);
|
||||
expect(rBack.status).toBe(200);
|
||||
expect(rBack.data.status).toBe('review_required');
|
||||
|
||||
await deleteSimulation(redteamToken, simRT.id);
|
||||
await deleteSimulation(redteamToken, simSOC.id);
|
||||
|
||||
@@ -78,7 +78,7 @@ test.describe('US-14 — technique tags UI', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-14.1 — MitreTechniquesField shows tags, Add technique + Quick search buttons, empty state', async ({
|
||||
test('AC-14.1 — MitreTechniquesField shows empty state, matrix icon + inline search input', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
@@ -88,13 +88,13 @@ test.describe('US-14 — technique tags UI', () => {
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Empty state message visible when no techniques
|
||||
await expect(
|
||||
page.getByText(/no techniques selected.*matrix.*quick search/i),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/no techniques selected/i)).toBeVisible();
|
||||
|
||||
// Add technique and Quick search buttons present
|
||||
await expect(page.getByRole('button', { name: /add technique/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /quick search/i })).toBeVisible();
|
||||
// Matrix icon button present (sprint 4: replaces "Add technique" text button)
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible();
|
||||
|
||||
// Inline search placeholder present (sprint 4: replaces "Quick search" text button)
|
||||
await expect(page.getByText(/search technique/i)).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
@@ -154,7 +154,7 @@ test.describe('US-14 — technique tags UI', () => {
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-14.2 — Quick search: selecting technique appends as tag + auto-save', async ({
|
||||
test('AC-14.2 — inline search: selecting technique appends as tag + auto-save', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
@@ -163,7 +163,8 @@ test.describe('US-14 — technique tags UI', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /quick search/i }).click();
|
||||
// Sprint 4: click inline placeholder text to reveal the combobox
|
||||
await page.getByText(/search technique/i).click();
|
||||
|
||||
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||
await picker.fill('T1059');
|
||||
|
||||
@@ -124,7 +124,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Open the matrix modal via "Add technique"
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
@@ -158,7 +158,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -177,14 +177,14 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await techLabelBtn.click();
|
||||
|
||||
// Apply button should now show count = 1
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();
|
||||
|
||||
// Click again to deselect
|
||||
await techLabelBtn.click();
|
||||
|
||||
// When 0 selected and no initial selection: footer shows disabled "Clear all"
|
||||
await expect(dialog.getByRole('button', { name: /clear all/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply \d+ technique/i })).not.toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply \d+ item/i })).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
@@ -198,7 +198,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -227,7 +227,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -257,12 +257,13 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Initially no "selected" counter visible
|
||||
await expect(dialog).not.toContainText('1 selected');
|
||||
// Sprint 4: tactic header shows truncated "N sel." due to tight column width
|
||||
await expect(dialog).not.toContainText('1 sel');
|
||||
|
||||
// Use search to isolate T1059 so we can click the label button, not the chevron
|
||||
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||
@@ -275,8 +276,8 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Tactic header for Execution should now show "1 selected"
|
||||
await expect(dialog).toContainText('1 selected');
|
||||
// Sprint 4: tactic header shows "1 sel." (truncated) due to tight column width
|
||||
await expect(dialog).toContainText('1 sel');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
@@ -290,7 +291,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -310,7 +311,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await dialog.getByRole('button', { name: /phishing/i }).first().click();
|
||||
|
||||
// Apply (2 techniques selected)
|
||||
const applyBtn = dialog.getByRole('button', { name: /apply \d+ technique/i });
|
||||
const applyBtn = dialog.getByRole('button', { name: /apply \d+ item/i });
|
||||
await expect(applyBtn).toBeVisible();
|
||||
await applyBtn.click();
|
||||
|
||||
@@ -342,12 +343,12 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Apply button should already show 1 technique (from initial selection)
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();
|
||||
|
||||
// Cancel to discard
|
||||
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
||||
@@ -365,7 +366,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -377,7 +378,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();
|
||||
|
||||
// Cancel instead of Apply
|
||||
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
||||
@@ -399,7 +400,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -411,7 +412,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
||||
@@ -431,7 +432,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -451,7 +452,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -482,7 +483,7 @@ test.describe('US-15 — MITRE matrix modal', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ test.describe('US-16 — sprint 2 regression', () => {
|
||||
|
||||
// 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 ({
|
||||
test('AC-16.2 — MitreTechniquePicker accessible via inline search, appends tag on selection', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
@@ -204,8 +204,8 @@ test.describe('US-16 — sprint 2 regression', () => {
|
||||
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();
|
||||
// Sprint 4: click inline placeholder text to reveal the picker
|
||||
await page.getByText(/search technique/i).click();
|
||||
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||
await expect(picker).toBeVisible();
|
||||
|
||||
@@ -270,12 +270,12 @@ test.describe('US-16 — sprint 2 regression', () => {
|
||||
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();
|
||||
// Sprint 4: "Save Red Team" renamed to "Save"
|
||||
await expect(page.getByRole('button', { name: /^save$/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();
|
||||
// Sprint 4: matrix icon + inline search placeholder replace the old text buttons
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible();
|
||||
await expect(page.getByText(/search technique/i)).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
127
e2e/tests/us17-ui-polish.spec.ts
Normal file
127
e2e/tests/us17-ui-polish.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* US-17 — UI polish: dedup buttons + alignment + icons.
|
||||
* Covers AC-17.1 (single New button on EngagementsListPage)
|
||||
* and AC-17.3 (UsersAdminPage Create form alignment).
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
createEngagement,
|
||||
deleteEngagement,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const REDTEAM_USER = 'us17-redteam';
|
||||
const PASS = 'us17-pass-strong';
|
||||
|
||||
test.describe('US-17 — UI polish', () => {
|
||||
let redteamToken: string;
|
||||
let adminTok: string;
|
||||
let engagementId: number;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
adminTok = await adminToken();
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
// Seed one engagement so the list is non-empty (EmptyState won't show extra "New" link)
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-17 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-17.1 — EngagementsListPage has exactly one "New" CTA button', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
// Wait for list to load so EmptyState doesn't briefly appear
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Sprint 4: single "New" button (+ icon). Old "Create engagement" duplicate removed.
|
||||
const newButtons = page.getByRole('link', { name: /new/i });
|
||||
// Should have at least one
|
||||
await expect(newButtons.first()).toBeVisible();
|
||||
|
||||
// Count all buttons/links that say "new engagement" or "create engagement"
|
||||
const newEngagementLinks = await page.getByRole('link', { name: /new/i }).count();
|
||||
const createEngagementLinks = await page.getByRole('link', { name: /create engagement/i }).count();
|
||||
const createButtons = await page.getByRole('button', { name: /create engagement/i }).count();
|
||||
|
||||
// Exactly one "New" CTA — zero "Create engagement" duplicates
|
||||
expect(newEngagementLinks).toBe(1);
|
||||
expect(createEngagementLinks).toBe(0);
|
||||
expect(createButtons).toBe(0);
|
||||
});
|
||||
|
||||
test('AC-17.1 — "New" button navigates to engagement creation form', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
|
||||
await page.getByRole('link', { name: /new/i }).first().click();
|
||||
await expect(page).toHaveURL(/\/engagements\/new/);
|
||||
});
|
||||
|
||||
test('AC-17.3 — UsersAdminPage Create account form: inputs and button aligned', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// UsersAdminPage is admin-only
|
||||
await seedTokenInStorage(context, adminTok);
|
||||
await page.goto('/admin/users');
|
||||
|
||||
// The form should be visible. Sprint 4: inputs use id="new-username" / id="new-password"
|
||||
const usernameInput = page.locator('#new-username').first();
|
||||
const passwordInput = page.locator('#new-password').first();
|
||||
const createBtn = page.getByRole('button', { name: /^create$/i }).first();
|
||||
|
||||
await expect(usernameInput).toBeVisible();
|
||||
await expect(passwordInput).toBeVisible();
|
||||
await expect(createBtn).toBeVisible();
|
||||
|
||||
// Alignment check via boundingBox: the 4-column grid layout puts username, password,
|
||||
// role, and button in the same row. All elements must be on the same vertical plane
|
||||
// (same y/height) and within the viewport.
|
||||
const usernameBox = await usernameInput.boundingBox();
|
||||
const passwordBox = await passwordInput.boundingBox();
|
||||
const btnBox = await createBtn.boundingBox();
|
||||
|
||||
expect(usernameBox).toBeTruthy();
|
||||
expect(passwordBox).toBeTruthy();
|
||||
expect(btnBox).toBeTruthy();
|
||||
|
||||
// All inputs are in separate columns — their y-positions (vertical alignment) should
|
||||
// be within one element-height of each other (same grid row).
|
||||
const yDiff = Math.abs(usernameBox!.y - passwordBox!.y);
|
||||
expect(yDiff).toBeLessThanOrEqual(usernameBox!.height + 4);
|
||||
|
||||
// All elements should be visible within the viewport (not overflowing off-screen)
|
||||
const viewportWidth = page.viewportSize()!.width;
|
||||
expect(usernameBox!.x + usernameBox!.width).toBeLessThanOrEqual(viewportWidth + 4);
|
||||
expect(passwordBox!.x + passwordBox!.width).toBeLessThanOrEqual(viewportWidth + 4);
|
||||
expect(btnBox!.x + btnBox!.width).toBeLessThanOrEqual(viewportWidth + 4);
|
||||
|
||||
// Username comes before password horizontally (left-to-right grid order)
|
||||
expect(usernameBox!.x).toBeLessThan(passwordBox!.x);
|
||||
// Button is positioned after the inputs (rightmost in the grid)
|
||||
expect(btnBox!.x).toBeGreaterThan(passwordBox!.x);
|
||||
});
|
||||
});
|
||||
225
e2e/tests/us18-done-readonly-reopen.spec.ts
Normal file
225
e2e/tests/us18-done-readonly-reopen.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* US-18 — Simulation `done` = read-only + Reopen.
|
||||
* Covers AC-18.1 → AC-18.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 = 'us18-redteam';
|
||||
const SOC_USER = 'us18-soc';
|
||||
const PASS = 'us18-pass-strong';
|
||||
|
||||
interface Simulation {
|
||||
id: number;
|
||||
status: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function createSimulation(token: string, engagementId: number, name = 'US-18 sim'): Promise<Simulation> {
|
||||
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
/** Drive a simulation from pending → in_progress → review_required → done */
|
||||
async function driveSimToDone(token: string, simId: number): Promise<void> {
|
||||
const c = makeClient(token);
|
||||
await c.patch(`/simulations/${simId}`, { name: 'trigger in_progress' });
|
||||
await c.post(`/simulations/${simId}/transition`, { to: 'review_required' });
|
||||
await c.post(`/simulations/${simId}/transition`, { to: 'done' });
|
||||
}
|
||||
|
||||
test.describe('US-18 — done read-only + Reopen', () => {
|
||||
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-18 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-18.1 — PATCH on done simulation returns 409 (redteam)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done redteam');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'should fail' });
|
||||
expect(r.status).toBe(409);
|
||||
expect(r.data.error).toMatch(/simulation is done — reopen first/i);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.1 — PATCH on done simulation returns 409 (soc)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done soc');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
// SOC tries to PATCH soc_comment on a done sim → 409
|
||||
const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, { soc_comment: 'late note' });
|
||||
expect(r.status).toBe(409);
|
||||
expect(r.data.error).toMatch(/simulation is done — reopen first/i);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.1 — PATCH on done simulation returns 409 (admin)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done admin');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
const tok = await adminToken();
|
||||
|
||||
const r = await makeClient(tok).patch(`/simulations/${sim.id}`, { name: 'admin override' });
|
||||
expect(r.status).toBe(409);
|
||||
expect(r.data.error).toMatch(/simulation is done — reopen first/i);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.2 — Reopen: done → review_required via transition (redteam)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.2 reopen redteam');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
const r = await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('review_required');
|
||||
expect(r.data.updated_at).toBeTruthy();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.2 — Reopen: done → review_required via transition (soc)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.2 reopen soc');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
const r = await makeClient(socToken).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-18.3 — review_required from pending/in_progress stays admin/redteam only (not soc)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.3 soc cannot mark review');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' });
|
||||
// SOC cannot mark in_progress → review_required
|
||||
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-18.3 — other transitions from done still return 409', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.3 done bad transition');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
// Trying to go done → done or done → in_progress should 409
|
||||
const r1 = await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'done' });
|
||||
expect(r1.status).toBe(409);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.4 — SimulationFormPage done: all fields disabled, only Reopen button visible', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.4 done UI');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Read-only banner visible
|
||||
await expect(page.getByText(/done.*read-only|read-only.*done/i)).toBeVisible();
|
||||
|
||||
// Name field disabled
|
||||
const nameField = page.locator('#sim-name');
|
||||
await expect(nameField).toBeDisabled();
|
||||
|
||||
// Reopen button visible
|
||||
await expect(page.getByRole('button', { name: /reopen/i })).toBeVisible();
|
||||
|
||||
// Save RT, Save SOC, Mark for review, Close, Delete — all absent
|
||||
await expect(page.getByRole('button', { name: /save/i })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /mark for review/i })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^close$/i })).not.toBeVisible();
|
||||
|
||||
// MitreTechniquesField in read-only mode: no matrix icon, no input
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.5 — Reopen via UI: toast appears, badge updates, fields editable', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.5 reopen UI');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Click Reopen
|
||||
await page.getByRole('button', { name: /reopen/i }).click();
|
||||
|
||||
// Toast: "Simulation reopened"
|
||||
await expect(page.getByText(/simulation reopened/i)).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Badge updates to review_required
|
||||
const badge = page.getByTestId('simulation-status-badge');
|
||||
await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 });
|
||||
|
||||
// Fields become editable again (name field enabled)
|
||||
await expect(page.locator('#sim-name')).toBeEnabled({ timeout: 3_000 });
|
||||
|
||||
// Reopen button gone; Save button now visible
|
||||
await expect(page.getByRole('button', { name: /reopen/i })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /save/i }).first()).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-18.5 — after Reopen, PATCH succeeds (no longer 409)', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-18.5 PATCH after reopen');
|
||||
await driveSimToDone(redteamToken, sim.id);
|
||||
|
||||
// Reopen via API
|
||||
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
|
||||
// Now PATCH should succeed
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { soc_comment: 'updated' });
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
});
|
||||
199
e2e/tests/us19-engagement-auto-status.spec.ts
Normal file
199
e2e/tests/us19-engagement-auto-status.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* US-19 — Engagement auto-status: planned → active.
|
||||
* Covers AC-19.1 → AC-19.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 = 'us19-redteam';
|
||||
const PASS = 'us19-pass-strong';
|
||||
|
||||
interface Simulation { id: number; status: string; [key: string]: unknown; }
|
||||
|
||||
async function createSimulation(token: string, engagementId: number, name = 'US-19 sim'): Promise<Simulation> {
|
||||
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
async function getEngagement(token: string, eid: number): Promise<{ status: string }> {
|
||||
const r = await makeClient(token).get(`/engagements/${eid}`);
|
||||
return r.data as { status: string };
|
||||
}
|
||||
|
||||
test.describe('US-19 — engagement auto-status', () => {
|
||||
let redteamToken: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const tok = await adminToken();
|
||||
await deleteUserByUsername(tok, REDTEAM_USER);
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
test('AC-19.1 — engagement stays planned when sim is created (no auto-transition yet)', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 stay planned',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
// Creating a simulation alone does NOT activate engagement
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 created');
|
||||
const engData = await getEngagement(redteamToken, eng.id);
|
||||
expect(engData.status).toBe('planned');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.1 — engagement auto-activates when sim transitions to in_progress (via PATCH redteam field)', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 auto-activate',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
expect(eng.status).toBe('planned');
|
||||
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 trigger');
|
||||
// PATCH a redteam field → auto-transition sim to in_progress → engagement → active
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('in_progress');
|
||||
|
||||
const engData = await getEngagement(redteamToken, eng.id);
|
||||
expect(engData.status).toBe('active');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.1 — engagement auto-activates when sim transitions via technique_ids', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 technique activate',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 technique');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('in_progress');
|
||||
|
||||
const engData = await getEngagement(redteamToken, eng.id);
|
||||
expect(engData.status).toBe('active');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.1 — engagement auto-activates when sim transitions via tactic_ids', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 tactic activate',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 tactic');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('in_progress');
|
||||
|
||||
const engData = await getEngagement(redteamToken, eng.id);
|
||||
expect(engData.status).toBe('active');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.2 — already active engagement stays active (no double-transition)', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 already active',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
// First sim activates the engagement
|
||||
const sim1 = await createSimulation(redteamToken, eng.id, 'AC-19.2 first');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim1.id}`, { name: 'trigger 1' });
|
||||
const engAfterFirst = await getEngagement(redteamToken, eng.id);
|
||||
expect(engAfterFirst.status).toBe('active');
|
||||
|
||||
// Second sim trigger — engagement stays active (not planned, not any other status)
|
||||
const sim2 = await createSimulation(redteamToken, eng.id, 'AC-19.2 second');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim2.id}`, { name: 'trigger 2' });
|
||||
const engAfterSecond = await getEngagement(redteamToken, eng.id);
|
||||
expect(engAfterSecond.status).toBe('active');
|
||||
|
||||
await deleteSimulation(redteamToken, sim1.id);
|
||||
await deleteSimulation(redteamToken, sim2.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.3 — no backward auto transition: engagement status never goes back to planned', async () => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 no backward',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.3 backward');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' });
|
||||
const engActive = await getEngagement(redteamToken, eng.id);
|
||||
expect(engActive.status).toBe('active');
|
||||
|
||||
// Deleting the sim does not revert engagement to planned
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
const engAfterDelete = await getEngagement(redteamToken, eng.id);
|
||||
expect(engAfterDelete.status).toBe('active');
|
||||
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
|
||||
test('AC-19.4 — frontend invalidates engagement cache after simulation PATCH (badge updates without reload)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-19 frontend cache',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
|
||||
const sim = await createSimulation(redteamToken, eng.id, 'AC-19.4 cache');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
// Navigate to engagement detail — engagement shows "planned"
|
||||
await page.goto(`/engagements/${eng.id}`);
|
||||
// Status badge should be planned initially
|
||||
const statusBadge = page.getByTestId('engagement-status-badge').first();
|
||||
if (await statusBadge.count() > 0) {
|
||||
await expect(statusBadge).toContainText(/planned/i);
|
||||
}
|
||||
|
||||
// Now trigger in_progress via form
|
||||
await page.goto(`/engagements/${eng.id}/simulations/${sim.id}/edit`);
|
||||
const nameField = page.locator('#sim-name');
|
||||
await nameField.fill('trigger auto-active');
|
||||
await page.getByRole('button', { name: /save/i }).first().click();
|
||||
|
||||
// Navigate back to engagement — status should now show active (cache invalidated)
|
||||
await page.goto(`/engagements/${eng.id}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByText(/active/i).first()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
await deleteEngagement(redteamToken, eng.id);
|
||||
});
|
||||
});
|
||||
235
e2e/tests/us20-matrix-fits-modal.spec.ts
Normal file
235
e2e/tests/us20-matrix-fits-modal.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* US-20 — MITRE matrix: attack.mitre.org look + no horizontal scroll.
|
||||
* Covers AC-20.1 (max-w-[98vw]) and AC-20.4 (no horizontal scroll via boundingBox).
|
||||
* AC-20.5 (sub-technique expand preserved) spot-checked.
|
||||
*/
|
||||
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 = 'us20-redteam';
|
||||
const PASS = 'us20-pass-strong';
|
||||
|
||||
interface Simulation { id: number; [key: string]: unknown; }
|
||||
|
||||
async function createSimulation(token: string, engagementId: number, name = 'US-20 sim'): Promise<Simulation> {
|
||||
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
test.describe('US-20 — MITRE matrix fits modal', () => {
|
||||
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-20 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-20.1 — modal max-width is 98vw (fits within viewport)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-20.1 width');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Open matrix via the grid icon button
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const dialogBox = await dialog.boundingBox();
|
||||
const viewportWidth = page.viewportSize()!.width;
|
||||
|
||||
expect(dialogBox).toBeTruthy();
|
||||
// Modal must not exceed viewport width (98vw)
|
||||
expect(dialogBox!.width).toBeLessThanOrEqual(viewportWidth * 0.99);
|
||||
// Modal must be visible (has meaningful width)
|
||||
expect(dialogBox!.width).toBeGreaterThan(600);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-20.4 — matrix body has NO horizontal scroll at 1280px viewport', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-20.4 no scroll');
|
||||
|
||||
// Force 1280×720 viewport (default in playwright.config.ts)
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The matrix body container must not overflow horizontally.
|
||||
// We check scrollWidth <= clientWidth on the overflow body element.
|
||||
const hasHorizontalScroll = await page.evaluate(() => {
|
||||
// Find the element with overflow-y-auto / overflow-x-hidden
|
||||
const dialogs = document.querySelectorAll('[role="dialog"]');
|
||||
for (const d of dialogs) {
|
||||
// The body is the flex-1 scrollable div inside the dialog
|
||||
const body = d.querySelector('.overflow-y-auto, .overflow-x-hidden');
|
||||
if (body) {
|
||||
return body.scrollWidth > body.clientWidth + 2; // 2px tolerance
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(hasHorizontalScroll).toBe(false);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-20.4 — all 12 tactic columns visible without scrolling at 1280px', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-20.4 tactics visible');
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// All 12 canonical tactics must be in the DOM (use first() to avoid strict mode violation
|
||||
// when tactic name appears in multiple technique titles, e.g. "Execution" appears in
|
||||
// technique sub-names).
|
||||
const expectedTactics = [
|
||||
'Initial Access', 'Execution', 'Persistence', 'Privilege Escalation',
|
||||
'Defense Evasion', 'Credential Access', 'Discovery', 'Lateral Movement',
|
||||
'Collection', 'Command and Control', 'Exfiltration', 'Impact',
|
||||
];
|
||||
for (const tactic of expectedTactics) {
|
||||
await expect(dialog.getByText(tactic, { exact: false }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
// The dialog itself must not have a scrollbar (overflow-x-hidden)
|
||||
const dialogBox = await dialog.boundingBox();
|
||||
const viewportWidth = page.viewportSize()!.width;
|
||||
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(viewportWidth + 2);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-20.5 — sub-technique expand/collapse still works after layout overhaul', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-20.5 expand');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Expand T1059 via chevron
|
||||
const expandBtn = dialog.getByRole('button', { name: /expand T1059/i });
|
||||
await expect(expandBtn).toBeVisible();
|
||||
await expandBtn.click();
|
||||
|
||||
// T1059.001 visible after expand
|
||||
await expect(dialog).toContainText('T1059.001');
|
||||
|
||||
// Collapse
|
||||
await dialog.getByRole('button', { name: /collapse T1059/i }).click();
|
||||
await expect(dialog).not.toContainText('T1059.001');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// NIT code-reviewer + AC-15.5 regression: Tab focus-trap cycle in MitreMatrixModal
|
||||
test('AC-15.5 regression — MitreMatrixModal Tab key cycles focus, Shift+Tab reverses', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 focus trap');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Collect all focusable elements in the dialog
|
||||
const focusableCount = await dialog.evaluate((el) => {
|
||||
const focusables = el.querySelectorAll(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
return focusables.length;
|
||||
});
|
||||
expect(focusableCount).toBeGreaterThan(1);
|
||||
|
||||
// Focus the last focusable element
|
||||
await dialog.evaluate((el) => {
|
||||
const focusables = Array.from(el.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
));
|
||||
focusables[focusables.length - 1].focus();
|
||||
});
|
||||
|
||||
// Tab from last → should wrap to first (focus trap)
|
||||
await page.keyboard.press('Tab');
|
||||
const activeAfterTab = await dialog.evaluate((el) => {
|
||||
const focusables = Array.from(el.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
));
|
||||
return focusables.indexOf(document.activeElement as HTMLElement);
|
||||
});
|
||||
// After Tab from last, focus should be on first (index 0)
|
||||
expect(activeAfterTab).toBe(0);
|
||||
|
||||
// Shift+Tab from first → should wrap to last
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
const activeAfterShiftTab = await dialog.evaluate((el) => {
|
||||
const focusables = Array.from(el.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
));
|
||||
return focusables.indexOf(document.activeElement as HTMLElement);
|
||||
});
|
||||
// After Shift+Tab from first, focus should be on last
|
||||
expect(activeAfterShiftTab).toBe(focusableCount - 1);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
});
|
||||
291
e2e/tests/us21-tactic-selection.spec.ts
Normal file
291
e2e/tests/us21-tactic-selection.spec.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* US-21 — Tactic selection (TA-id tags).
|
||||
* Covers AC-21.4 → AC-21.7 (API + UI).
|
||||
* AC-21.1/2/3 (model + migration + serialization) tested via API assertions.
|
||||
*/
|
||||
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 = 'us21-redteam';
|
||||
const SOC_USER = 'us21-soc';
|
||||
const PASS = 'us21-pass-strong';
|
||||
|
||||
interface Simulation { id: number; status: string; tactics: { id: string; name: string }[]; [key: string]: unknown; }
|
||||
|
||||
async function createSimulation(token: string, engagementId: number, name = 'US-21 sim'): Promise<Simulation> {
|
||||
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
test.describe('US-21 — tactic selection', () => {
|
||||
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-21 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-21.1/2/3 — model + serialization
|
||||
test('AC-21.3 — new simulation has tactics=[] in serialisation', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.3 empty');
|
||||
expect(Array.isArray(sim.tactics)).toBe(true);
|
||||
expect(sim.tactics).toHaveLength(0);
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-21.4 — PATCH tactic_ids validation
|
||||
test('AC-21.4 — PATCH tactic_ids: valid TA-id stored, enriched name in response', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 valid');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
tactic_ids: ['TA0007', 'TA0001'],
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(Array.isArray(r.data.tactics)).toBe(true);
|
||||
expect(r.data.tactics).toHaveLength(2);
|
||||
const disc = r.data.tactics.find((t: { id: string }) => t.id === 'TA0007');
|
||||
expect(disc).toBeTruthy();
|
||||
expect(disc.name).toBe('Discovery');
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.4 — PATCH tactic_ids: unknown TA-id → 400', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 unknown');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
tactic_ids: ['TA9999'],
|
||||
});
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.data.error).toMatch(/unknown tactic id.*TA9999/i);
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.4 — PATCH tactic_ids: dedup preserves order', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 dedup');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
tactic_ids: ['TA0007', 'TA0001', 'TA0007'],
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const ids = r.data.tactics.map((t: { id: string }) => t.id);
|
||||
expect(ids).toEqual(['TA0007', 'TA0001']);
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-21.5 — SOC gate + auto-transition
|
||||
test('AC-21.5 — SOC cannot PATCH tactic_ids → 403', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 soc block');
|
||||
// Advance to review_required so SOC has access
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' });
|
||||
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
|
||||
const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
expect(r.status).toBe(403);
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.5 — non-empty tactic_ids triggers auto-transition pending→in_progress', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 auto-transition');
|
||||
expect(sim.status).toBe('pending');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('in_progress');
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.5 — empty tactic_ids does NOT trigger auto-transition', async () => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 no-trigger');
|
||||
expect(sim.status).toBe('pending');
|
||||
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: [] });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.data.status).toBe('pending');
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-21.6 — matrix modal tactic header clickable
|
||||
test('AC-21.6 — clicking tactic header in modal toggles tactic selection', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.6 tactic click');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for matrix to load — tactic header title: "Discovery (TA0007) — click to tag this tactic"
|
||||
const discoveryHeader = dialog.locator('button[title*="TA0007"]');
|
||||
await expect(discoveryHeader).toBeVisible({ timeout: 10_000 });
|
||||
await discoveryHeader.click();
|
||||
|
||||
// Apply button shows at least 1 selection (the tactic)
|
||||
await expect(dialog.getByRole('button', { name: /apply \d+/i })).toBeVisible();
|
||||
|
||||
// Click again to deselect
|
||||
await discoveryHeader.click();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.6 — Apply from modal includes tactic in result (auto-save)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.6 apply tactic');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Tactic header title: "Discovery (TA0007) — click to tag this tactic"
|
||||
const discoveryBtn = dialog.locator('button[title*="TA0007"]');
|
||||
await expect(discoveryBtn).toBeVisible({ timeout: 10_000 });
|
||||
await discoveryBtn.click();
|
||||
|
||||
const applyBtn = dialog.getByRole('button', { name: /apply \d+/i });
|
||||
await expect(applyBtn).toBeVisible();
|
||||
await applyBtn.click();
|
||||
|
||||
// Modal closes
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Tactic chip appears after auto-save
|
||||
await expect(page.getByTestId('mitre-tactic-tag')).toBeVisible({ timeout: 8_000 });
|
||||
await expect(page.getByTestId('techniques-tag-list')).toContainText('TA0007');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-21.7 — tactic chips in MitreTechniquesField
|
||||
test('AC-21.7 — tactic chips display TA-id and have × for removal', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 tactic chip');
|
||||
// Seed via API
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
const tacticTag = page.getByTestId('mitre-tactic-tag');
|
||||
await expect(tacticTag).toBeVisible();
|
||||
await expect(tacticTag).toContainText('TA0007');
|
||||
|
||||
// Title attribute has id — name
|
||||
const title = await tacticTag.getAttribute('title');
|
||||
expect(title).toMatch(/TA0007/);
|
||||
expect(title).toMatch(/Discovery/);
|
||||
|
||||
// × button for removal
|
||||
await expect(page.getByRole('button', { name: /remove TA0007/i })).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.7 — removing tactic chip auto-saves', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 remove tactic');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await expect(page.getByTestId('mitre-tactic-tag')).toBeVisible();
|
||||
await page.getByRole('button', { name: /remove TA0007/i }).click();
|
||||
|
||||
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId('mitre-tactic-tag')).not.toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-21.7 — tactic chips visually distinct from technique chips (different class)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 style');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
tactic_ids: ['TA0007'],
|
||||
technique_ids: ['T1059'],
|
||||
});
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
const tacticTag = page.getByTestId('mitre-tactic-tag');
|
||||
const techTag = page.getByTestId('mitre-technique-tag');
|
||||
await expect(tacticTag).toBeVisible();
|
||||
await expect(techTag).toBeVisible();
|
||||
|
||||
// Tactic: bg-primary (filled) vs technique: bg-primary-soft
|
||||
const tacticCls = await tacticTag.getAttribute('class');
|
||||
const techCls = await techTag.getAttribute('class');
|
||||
expect(tacticCls).toMatch(/bg-primary/);
|
||||
// Technique should NOT have the solid bg-primary (just bg-primary-soft)
|
||||
expect(techCls).toMatch(/bg-primary-soft/);
|
||||
// They should differ visually
|
||||
expect(tacticCls).not.toBe(techCls);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// NIT code-reviewer: +N suffix in SimulationList MITRE column
|
||||
test('AC-21.7 — SimulationList MITRE column shows first id + "+N" for mixed tactics+techniques', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 +N suffix');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
tactic_ids: ['TA0007'],
|
||||
technique_ids: ['T1059', 'T1078'],
|
||||
});
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}`);
|
||||
|
||||
// The row for this simulation should show "TA0007 +2" in the MITRE column
|
||||
// (1 tactic TA0007 is first, then +2 for T1059 and T1078)
|
||||
const simRow = page.getByRole('row').filter({ hasText: 'AC-21.7 +N suffix' });
|
||||
await expect(simRow).toBeVisible({ timeout: 5_000 });
|
||||
await expect(simRow).toContainText('TA0007 +2');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
});
|
||||
250
e2e/tests/us22-mitre-input-redesign.spec.ts
Normal file
250
e2e/tests/us22-mitre-input-redesign.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* US-22 — Refonte input MITRE dans le form.
|
||||
* Covers AC-22.1 → AC-22.5.
|
||||
* Key change: no "Add technique" / "Quick search" text buttons.
|
||||
* Instead: inline autocomplete input + grid icon for matrix.
|
||||
* Chips show T-id only (name in title=).
|
||||
*/
|
||||
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 = 'us22-redteam';
|
||||
const SOC_USER = 'us22-soc';
|
||||
const PASS = 'us22-pass-strong';
|
||||
|
||||
interface Simulation { id: number; [key: string]: unknown; }
|
||||
|
||||
async function createSimulation(token: string, engagementId: number, name = 'US-22 sim'): Promise<Simulation> {
|
||||
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
test.describe('US-22 — MITRE input redesign', () => {
|
||||
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-22 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-22.1 — layout: inline autocomplete input + matrix icon present, NO text buttons', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 layout');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Matrix icon button present
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible();
|
||||
|
||||
// Search input placeholder visible (inline autocomplete)
|
||||
await expect(page.getByText(/search technique/i)).toBeVisible();
|
||||
|
||||
// No old-style text buttons
|
||||
await expect(page.getByRole('button', { name: /add technique/i })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /quick search/i })).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-22.1 — inline autocomplete: click input shows combobox, type shows dropdown', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 autocomplete');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Click the fake-input placeholder to reveal the combobox
|
||||
await page.getByText(/search technique/i).click();
|
||||
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||
await expect(picker).toBeVisible();
|
||||
|
||||
// Type to get results
|
||||
await picker.fill('T1059');
|
||||
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
|
||||
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Select via keyboard
|
||||
await picker.press('ArrowDown');
|
||||
await picker.press('Enter');
|
||||
|
||||
// Tag appears
|
||||
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059', { timeout: 5_000 });
|
||||
// Auto-save toast
|
||||
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-22.1 — matrix icon opens MitreMatrixModal', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 matrix icon');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByLabel(/open mitre matrix/i).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('dialog')).toContainText(/mitre att&?ck matrix/i);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-22.2 — chips show T-id only, name in title
|
||||
test('AC-22.2 — technique chips display T-id only, name in title= attribute', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.2 chip format');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
const chip = page.getByTestId('mitre-technique-tag').first();
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
// Text content is T-id only
|
||||
const text = await chip.textContent();
|
||||
expect(text?.trim()).toMatch(/^T1059/);
|
||||
// Must NOT contain the full name inline
|
||||
expect(text).not.toMatch(/Command and Scripting Interpreter/);
|
||||
|
||||
// Name appears in title attribute
|
||||
const title = await chip.getAttribute('title');
|
||||
expect(title).toMatch(/T1059/);
|
||||
expect(title).toMatch(/Command and Scripting Interpreter/);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-22.2 — tactic chips display TA-id only, name in title= attribute', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.2 tactic chip format');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
const chip = page.getByTestId('mitre-tactic-tag').first();
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
const text = await chip.textContent();
|
||||
expect(text?.trim()).toMatch(/^TA0007/);
|
||||
expect(text).not.toMatch(/Discovery/);
|
||||
|
||||
const title = await chip.getAttribute('title');
|
||||
expect(title).toMatch(/TA0007/);
|
||||
expect(title).toMatch(/Discovery/);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-22.4 — empty state
|
||||
test('AC-22.4 — empty state: "No techniques selected" visible, input and matrix icon still shown', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.4 empty');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Empty state message
|
||||
await expect(page.getByText(/no techniques selected/i)).toBeVisible();
|
||||
|
||||
// Input and matrix icon still present in non-disabled mode
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible();
|
||||
await expect(page.getByText(/search technique/i)).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
// AC-22.5 — read-only mode
|
||||
test('AC-22.5 — SOC on in_progress sim: chips visible (no ×), input + matrix icon hidden', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.5 soc readonly');
|
||||
// Add a technique and advance to review_required for SOC access
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] });
|
||||
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
|
||||
await seedTokenInStorage(context, socToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Chip is visible
|
||||
await expect(page.getByTestId('mitre-technique-tag')).toBeVisible();
|
||||
|
||||
// No × remove button (read-only for SOC on technique chips)
|
||||
await expect(page.getByRole('button', { name: /remove T1059/i })).not.toBeVisible();
|
||||
|
||||
// Matrix icon and input hidden in disabled mode
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible();
|
||||
await expect(page.getByText(/search technique/i)).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-22.5 — done sim: all chips read-only, no input', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.5 done readonly');
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] });
|
||||
// Drive to done
|
||||
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'done' });
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Chip visible
|
||||
await expect(page.getByTestId('mitre-technique-tag')).toBeVisible();
|
||||
// No × remove
|
||||
await expect(page.getByRole('button', { name: /remove T1059/i })).not.toBeVisible();
|
||||
// No matrix icon
|
||||
await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
});
|
||||
175
e2e/tests/us23-dark-mode.spec.ts
Normal file
175
e2e/tests/us23-dark-mode.spec.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* US-23 — Dark mode.
|
||||
* Covers AC-23.1 (toggle in topbar), AC-23.2 (3-state cycle), AC-23.3 (localStorage persistence).
|
||||
* AC-23.4/5/6 (Tailwind tokens, component audit, screenshots) are frontend-builder scope.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const REDTEAM_USER = 'us23-redteam';
|
||||
const PASS = 'us23-pass-strong';
|
||||
|
||||
test.describe('US-23 — dark mode', () => {
|
||||
let redteamToken: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const tok = await adminToken();
|
||||
await deleteUserByUsername(tok, REDTEAM_USER);
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
|
||||
test('AC-23.1 — theme toggle button is visible in the topbar', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
|
||||
// The theme toggle has aria-label containing "Theme:" and the current mode
|
||||
const themeBtn = page.getByRole('button', { name: /theme:/i });
|
||||
await expect(themeBtn).toBeVisible();
|
||||
|
||||
// Shows current theme label (Light, Dark, or System)
|
||||
const label = await themeBtn.textContent();
|
||||
expect(label).toMatch(/light|dark|system/i);
|
||||
});
|
||||
|
||||
test('AC-23.2 — toggle cycles through 3 states: system → light → dark → system', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
// Clear any stored theme via evaluate (not addInitScript — that would fire on every load)
|
||||
await page.evaluate(() => localStorage.removeItem('mimic-theme'));
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const themeBtn = page.getByRole('button', { name: /theme:/i });
|
||||
await expect(themeBtn).toBeVisible();
|
||||
|
||||
// Collect 4 states to confirm a full cycle
|
||||
const states: string[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const label = await themeBtn.textContent();
|
||||
states.push(label?.trim().toLowerCase() ?? '');
|
||||
await themeBtn.click();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
// Must contain all 3 modes within the cycle
|
||||
expect(states.some(s => s.includes('system'))).toBe(true);
|
||||
expect(states.some(s => s.includes('light'))).toBe(true);
|
||||
expect(states.some(s => s.includes('dark'))).toBe(true);
|
||||
// 4th state must equal 1st (full cycle completed)
|
||||
expect(states[3]).toBe(states[0]);
|
||||
});
|
||||
|
||||
test('AC-23.3 — theme persists in localStorage under "mimic-theme"', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
|
||||
// Clear any stored theme via evaluate (NOT addInitScript — that runs on reload too)
|
||||
await page.evaluate(() => localStorage.removeItem('mimic-theme'));
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const themeBtn = page.getByRole('button', { name: /theme:/i });
|
||||
await expect(themeBtn).toBeVisible();
|
||||
|
||||
// Click until we reach "dark"
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const label = await themeBtn.textContent();
|
||||
if (label?.toLowerCase().includes('dark')) break;
|
||||
await themeBtn.click();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
// Read localStorage — must be 'dark'
|
||||
const stored = await page.evaluate(() => localStorage.getItem('mimic-theme'));
|
||||
expect(stored).toBe('dark');
|
||||
|
||||
// Reload page — should restore dark mode (localStorage persists across reload)
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const themeAfterReload = page.getByRole('button', { name: /theme:/i });
|
||||
await expect(themeAfterReload).toBeVisible();
|
||||
const labelAfterReload = await themeAfterReload.textContent();
|
||||
expect(labelAfterReload?.toLowerCase()).toContain('dark');
|
||||
|
||||
// html element should have class "dark"
|
||||
const hasDarkClass = await page.evaluate(() => document.documentElement.classList.contains('dark'));
|
||||
expect(hasDarkClass).toBe(true);
|
||||
});
|
||||
|
||||
test('AC-23.3 — default is "system" when no localStorage value', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
// Clear theme via evaluate then reload so page renders with no stored theme
|
||||
await page.evaluate(() => localStorage.removeItem('mimic-theme'));
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const stored = await page.evaluate(() => localStorage.getItem('mimic-theme'));
|
||||
// Default state: localStorage not yet set (null) OR set to 'system'
|
||||
expect(stored === null || stored === 'system').toBe(true);
|
||||
|
||||
const themeBtn = page.getByRole('button', { name: /theme:/i });
|
||||
const label = await themeBtn.textContent();
|
||||
// Initial label should be System (or light if system resolves to light)
|
||||
expect(label?.toLowerCase()).toMatch(/system|light|dark/);
|
||||
});
|
||||
|
||||
test('AC-23.1 — dark mode: html has class "dark" when dark selected', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
// Set dark theme via evaluate then reload so React reads it on mount
|
||||
await page.evaluate(() => localStorage.setItem('mimic-theme', 'dark'));
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for React to apply the dark class via useEffect
|
||||
await page.waitForFunction(() => document.documentElement.classList.contains('dark'), { timeout: 3_000 });
|
||||
const hasDarkClass = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark'),
|
||||
);
|
||||
expect(hasDarkClass).toBe(true);
|
||||
});
|
||||
|
||||
test('AC-23.1 — light mode: html does NOT have class "dark" when light selected', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto('/engagements');
|
||||
await page.evaluate(() => localStorage.setItem('mimic-theme', 'light'));
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const hasDarkClass = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark'),
|
||||
);
|
||||
expect(hasDarkClass).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -198,8 +198,8 @@ test.describe('US-4 — engagement CRUD', () => {
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByText(REDTEAM_USER)).toBeVisible();
|
||||
|
||||
// Redteam sees the action buttons.
|
||||
await expect(page.getByRole('link', { name: /new engagement/i })).toBeVisible();
|
||||
// Redteam sees the action buttons. Sprint 4: "New engagement" renamed to "New".
|
||||
await expect(page.getByRole('link', { name: /^new$/i })).toBeVisible();
|
||||
await expect(row.getByRole('link', { name: /^edit$/i })).toBeVisible();
|
||||
await expect(row.getByRole('button', { name: /^delete$/i })).toBeVisible();
|
||||
|
||||
@@ -208,7 +208,7 @@ test.describe('US-4 — engagement CRUD', () => {
|
||||
await page.goto('/engagements');
|
||||
const rowAsSoc = page.getByRole('row', { name: /UI list sample/i });
|
||||
await expect(rowAsSoc).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /new engagement/i })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: /^new$/i })).toHaveCount(0);
|
||||
await expect(rowAsSoc.getByRole('link', { name: /^edit$/i })).toHaveCount(0);
|
||||
await expect(rowAsSoc.getByRole('button', { name: /^delete$/i })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -58,12 +58,12 @@ test.describe('US-5 — DESIGN.md fidelity, responsive, states', () => {
|
||||
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
|
||||
expect(chevronBg.replace(/\s/g, '')).toBe('rgb(2,74,216)');
|
||||
|
||||
// Topbar utility-strip is the ink slab (`bg-ink` → rgb(26, 26, 26)).
|
||||
// Sprint 4: topbar utility-strip uses `bg-slab` (#111827 → rgb(17,24,39)).
|
||||
const utilityBg = await page
|
||||
.locator('div.bg-ink.text-ink-on')
|
||||
.locator('div.bg-slab.text-slab-text')
|
||||
.first()
|
||||
.evaluate((el) => window.getComputedStyle(el).backgroundColor);
|
||||
expect(utilityBg.replace(/\s/g, '')).toBe('rgb(26,26,26)');
|
||||
expect(utilityBg.replace(/\s/g, '')).toBe('rgb(17,24,39)');
|
||||
|
||||
// Spot-check a few semantic class names live in the DOM (proves tokens are
|
||||
// wired through tailwind.config.ts and not ad-hoc hex values).
|
||||
|
||||
@@ -193,14 +193,15 @@ test.describe('US-8 — redteam fill simulation details', () => {
|
||||
await expect(nameField).toBeEnabled();
|
||||
|
||||
// Clear the name, try to save → client validation error
|
||||
// Sprint 4: "Save Red Team" button renamed to "Save"
|
||||
await nameField.fill('');
|
||||
await page.getByRole('button', { name: /save red team/i }).click();
|
||||
await page.getByRole('button', { name: /^save$/i }).click();
|
||||
await expect(page.getByText(/name is required/i)).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-8.6 — MITRE technique picker accessible via Quick search on the edit form', async ({
|
||||
test('AC-8.6 — MITRE technique picker accessible via inline search on the edit form', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
@@ -209,8 +210,8 @@ test.describe('US-8 — redteam fill simulation details', () => {
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search"
|
||||
await page.getByRole('button', { name: /quick search/i }).click();
|
||||
// Sprint 4: picker opens by clicking the inline placeholder text
|
||||
await page.getByText(/search technique/i).click();
|
||||
// MitreTechniquePicker renders an input with combobox role
|
||||
await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible();
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ConfirmDialog({
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div className="absolute inset-0 bg-ink/40" onClick={onCancel} aria-hidden="true" />
|
||||
<div className="modal-backdrop absolute inset-0" onClick={onCancel} aria-hidden="true" />
|
||||
<div className="relative card-product shadow-floating max-w-sm w-full mx-md flex flex-col gap-md">
|
||||
<h2 id="confirm-dialog-title" className="text-[20px] font-medium text-ink">
|
||||
{title}
|
||||
|
||||
@@ -28,13 +28,13 @@ export function Layout(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col bg-canvas">
|
||||
{/* utility-strip — ink slab, fine print */}
|
||||
<div className="bg-ink text-white text-[14px] h-9 flex items-center">
|
||||
{/* utility-strip — fixed dark slab, never inverts */}
|
||||
<div className="bg-slab text-slab-text text-[14px] h-9 flex items-center">
|
||||
<div className="mx-auto w-full max-w-page px-xl flex items-center justify-between">
|
||||
<span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span>
|
||||
{user ? (
|
||||
<div className="flex items-center gap-md">
|
||||
<span className="text-[12px] uppercase tracking-[0.5px] text-steel">
|
||||
<span className="text-[12px] uppercase tracking-[0.5px] text-slab-muted">
|
||||
{user.role}
|
||||
</span>
|
||||
<span className="text-[14px]">{user.username}</span>
|
||||
@@ -42,7 +42,7 @@ export function Layout(): JSX.Element {
|
||||
type="button"
|
||||
onClick={cycleTheme}
|
||||
aria-label={`Theme: ${themeLabel(theme)} — click to cycle`}
|
||||
className="flex items-center gap-xxs text-[12px] text-steel hover:text-white transition-colors"
|
||||
className="flex items-center gap-xxs text-[12px] text-slab-muted hover:text-slab-text transition-colors"
|
||||
>
|
||||
<ThemeIcon theme={theme} />
|
||||
<span className="uppercase tracking-[0.5px]">{themeLabel(theme)}</span>
|
||||
@@ -59,8 +59,8 @@ export function Layout(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* nav-bar-top — canvas with hairline */}
|
||||
<header className="bg-canvas border-b border-hairline">
|
||||
{/* nav-bar-top — paper gives dark-mode lift vs canvas body */}
|
||||
<header className="bg-paper border-b border-hairline">
|
||||
<div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between">
|
||||
<Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home">
|
||||
<span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden />
|
||||
@@ -101,9 +101,9 @@ export function Layout(): JSX.Element {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* footer — ink slab close */}
|
||||
<footer className="bg-ink text-white">
|
||||
<div className="mx-auto w-full max-w-page px-xl py-xl text-[12px] text-steel">
|
||||
{/* footer — fixed dark slab, never inverts */}
|
||||
<footer className="bg-slab text-slab-text">
|
||||
<div className="mx-auto w-full max-w-page px-xl py-xl text-[12px] text-slab-muted">
|
||||
Mimic — Internal Purple Team tooling. Authorized engagements only.
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -163,14 +163,14 @@ export function MitreMatrixModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-ink/60" onClick={onCancel} aria-hidden="true" />
|
||||
<div className="modal-backdrop absolute inset-0" onClick={onCancel} aria-hidden="true" />
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="matrix-modal-title"
|
||||
className="relative bg-canvas rounded-xl shadow-floating max-w-[98vw] max-h-[80vh] overflow-hidden flex flex-col"
|
||||
className="relative bg-canvas rounded-xl shadow-floating dark:shadow-floating-dark max-w-[98vw] max-h-[80vh] overflow-hidden flex flex-col"
|
||||
style={{ width: '1400px' }}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
|
||||
@@ -7,12 +7,13 @@ const LABELS: Record<SimulationStatus, string> = {
|
||||
done: 'Done',
|
||||
};
|
||||
|
||||
// pending=fog, in_progress=primary-soft, review_required=bloom-coral, done=storm-deep
|
||||
// Fixed colors — badge backgrounds are decorative/semantic, not themeable.
|
||||
// text-white is hardcoded (not text-canvas) so dark mode doesn't invert it to near-black.
|
||||
const STYLES: Record<SimulationStatus, string> = {
|
||||
pending: 'bg-fog text-charcoal border border-hairline',
|
||||
in_progress: 'bg-primary-soft text-primary-deep',
|
||||
review_required: 'bg-bloom-coral text-canvas',
|
||||
done: 'bg-storm-deep text-canvas',
|
||||
review_required: 'bg-bloom-coral text-white',
|
||||
done: 'bg-storm-deep text-white',
|
||||
};
|
||||
|
||||
export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element {
|
||||
|
||||
@@ -10,7 +10,7 @@ const STYLES: Record<EngagementStatus, string> = {
|
||||
// Outlined ink for planned (neutral), filled primary for active (engagement live),
|
||||
// outlined steel for closed (muted). Stays within DESIGN.md palette.
|
||||
planned: 'bg-canvas text-ink border border-ink',
|
||||
active: 'bg-primary text-ink-on',
|
||||
active: 'bg-primary text-white',
|
||||
closed: 'bg-cloud text-graphite border border-hairline',
|
||||
};
|
||||
|
||||
|
||||
@@ -15,10 +15,11 @@ export function ToastViewport(): JSX.Element {
|
||||
{toasts.map((t) => {
|
||||
const isError = t.kind === 'error';
|
||||
const isSuccess = t.kind === 'success';
|
||||
// Fixed colors: toasts don't theme (error=dark slab, success=primary blue)
|
||||
const surface = isError
|
||||
? 'bg-ink text-ink-on'
|
||||
? 'bg-slab text-slab-text'
|
||||
: isSuccess
|
||||
? 'bg-primary text-ink-on'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-canvas text-ink border border-hairline';
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { Engagement } from '@/api/types';
|
||||
import { useDeleteEngagement, useEngagementsList } from '@/hooks/useEngagements';
|
||||
@@ -41,7 +42,7 @@ export function EngagementsListPage(): JSX.Element {
|
||||
</div>
|
||||
{canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
+ New
|
||||
<Plus size={14} aria-hidden /> New
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
@@ -59,7 +60,7 @@ export function EngagementsListPage(): JSX.Element {
|
||||
action={
|
||||
canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
+ New engagement
|
||||
<Plus size={14} aria-hidden /> New
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
@@ -110,16 +110,34 @@ export function UsersAdminPage(): JSX.Element {
|
||||
|
||||
<section className="card-product flex flex-col gap-md">
|
||||
<h2 className="text-[20px] font-medium">Create account</h2>
|
||||
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-end">
|
||||
<FormField label="Username" htmlFor="new-username" required>
|
||||
{/*
|
||||
Option A structural fix (AC-17.3): labels / inputs / hints in 3 explicit grid rows
|
||||
so the browser can never misalign them by collapsing different-height cells.
|
||||
grid-rows-[auto_auto_auto] ensures row 1 = labels, row 2 = inputs, row 3 = hints.
|
||||
*/}
|
||||
<form
|
||||
onSubmit={onCreate}
|
||||
className="grid grid-cols-1 md:grid-cols-4 md:grid-rows-[auto_auto_auto] gap-x-md gap-y-xs"
|
||||
>
|
||||
{/* Row 1 — labels */}
|
||||
<label htmlFor="new-username" className="text-[14px] font-medium text-ink">
|
||||
Username <span className="text-bloom-deep">*</span>
|
||||
</label>
|
||||
<label htmlFor="new-password" className="text-[14px] font-medium text-ink">
|
||||
Password <span className="text-bloom-deep">*</span>
|
||||
</label>
|
||||
<label htmlFor="new-role" className="text-[14px] font-medium text-ink">
|
||||
Role <span className="text-bloom-deep">*</span>
|
||||
</label>
|
||||
<div aria-hidden="true" />
|
||||
|
||||
{/* Row 2 — inputs + button (all same height = h-11) */}
|
||||
<TextInput
|
||||
id="new-username"
|
||||
value={createForm.username}
|
||||
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Password" htmlFor="new-password" required hint="≥ 8 characters">
|
||||
<TextInput
|
||||
id="new-password"
|
||||
type="password"
|
||||
@@ -128,20 +146,21 @@ export function UsersAdminPage(): JSX.Element {
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Role" htmlFor="new-role" required>
|
||||
<Select
|
||||
id="new-role"
|
||||
value={createForm.role}
|
||||
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })}
|
||||
options={ROLE_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
<div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Row 3 — hints */}
|
||||
<div aria-hidden="true" />
|
||||
<span className="text-[12px] text-graphite">≥ 8 characters</span>
|
||||
<div aria-hidden="true" />
|
||||
<div aria-hidden="true" />
|
||||
</form>
|
||||
{createError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
--color-cloud: #1f2937;
|
||||
--color-fog: #374151;
|
||||
--color-steel: #4b5563;
|
||||
--color-hairline: #374151;
|
||||
--color-hairline: #4b5563;
|
||||
--color-ink: #f9fafb;
|
||||
--color-ink-soft: #e5e7eb;
|
||||
--color-ink-deep: #ffffff;
|
||||
@@ -67,6 +67,7 @@
|
||||
* DESIGN.md component recipes.
|
||||
* Buttons stay sharp (rounded-md = 4px); cards stay soft (rounded-xl = 16px).
|
||||
*/
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-xs bg-primary text-white uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||
}
|
||||
@@ -77,11 +78,12 @@
|
||||
@apply bg-steel cursor-not-allowed;
|
||||
}
|
||||
|
||||
/* btn-ink uses fixed dark slab so it doesn't invert in dark mode */
|
||||
.btn-ink {
|
||||
@apply inline-flex items-center justify-center gap-xs bg-ink text-white uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||
@apply inline-flex items-center justify-center gap-xs bg-slab text-white uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||
}
|
||||
.btn-ink:hover {
|
||||
@apply bg-ink-soft;
|
||||
@apply bg-paper;
|
||||
}
|
||||
.btn-ink:disabled {
|
||||
@apply bg-steel cursor-not-allowed;
|
||||
@@ -115,6 +117,14 @@
|
||||
.card-product {
|
||||
@apply bg-canvas rounded-xl p-xl shadow-soft-lift;
|
||||
}
|
||||
.dark .card-product {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
/* Fixed-color modal backdrop — must not use themed ink (inverts in dark mode) */
|
||||
.modal-backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.badge-pill-ink {
|
||||
@apply inline-flex items-center bg-ink text-white rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
|
||||
|
||||
@@ -34,6 +34,10 @@ const config: Config = {
|
||||
},
|
||||
charcoal: 'var(--color-charcoal)',
|
||||
graphite: 'var(--color-graphite)',
|
||||
// Fixed dark slab — never inverts in dark mode (utility strip, footer, dark bands)
|
||||
slab: '#111827',
|
||||
'slab-text': '#f9fafb',
|
||||
'slab-muted': '#6b7280',
|
||||
// Semantic / decorative — fixed (not themeable)
|
||||
bloom: {
|
||||
coral: '#ff5050',
|
||||
@@ -91,6 +95,8 @@ const config: Config = {
|
||||
boxShadow: {
|
||||
'soft-lift': '0 2px 8px rgba(26, 26, 26, 0.08)',
|
||||
floating: '0 8px 24px rgba(26, 26, 26, 0.12)',
|
||||
'soft-lift-dark': '0 2px 8px rgba(0, 0, 0, 0.32)',
|
||||
'floating-dark': '0 8px 24px rgba(0, 0, 0, 0.48)',
|
||||
},
|
||||
maxWidth: {
|
||||
page: '1366px',
|
||||
|
||||
@@ -306,7 +306,7 @@ describe('MitreMatrixModal', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const backdrop = document.querySelector('.bg-ink\\/60') as HTMLElement;
|
||||
const backdrop = document.querySelector('.modal-backdrop') as HTMLElement;
|
||||
if (backdrop) await user.click(backdrop);
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
|
||||
@@ -4,6 +4,44 @@ Recurring mistakes and the rule we adopted so the same issue doesn't bite twice.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 4 (closed 2026-05-27)
|
||||
|
||||
### Process — git status before declaring sprint complete (team-lead)
|
||||
**Context** : Sprint 3 §0 of the plan updated `SPEC.md`'s § Simulation line to plural multi-techniques. That edit sat in the sprint 3 worktree but was never committed; PR #6 merged the sprint 3 code WITHOUT the corresponding spec text. I rediscovered it at sprint 4 start with a stray `M SPEC.md` in the worktree, then carried over via `ba313a3`. Sprint 3 had shipped with SPEC and code briefly out of sync.
|
||||
**Lesson** : at sprint wrap-up, the team-lead's final pre-PR step MUST be `git status` followed by a 5-second scan for unstaged hunks. If anything is M/?? in the listing, decide explicitly (commit, stash, or abandon). Don't trust commit lists alone.
|
||||
|
||||
### Process — Endpoint round-trip mismatch caught by e2e, missed by 3 spec-reviewer passes (team-lead + spec-reviewer)
|
||||
**Context** : Sprint 3's `GET /api/mitre/matrix` returned `tactic_id` as a slug (`"discovery"`). Sprint 4 added `tactic_ids` PATCH validation against TA-format (`"TA0007"`). Both endpoints worked in isolation but the round-trip (matrix → frontend → PATCH) failed silently — frontend sent the slug from the matrix as a `tactic_ids` value, and the backend rejected with 400. Spec-reviewer ran 3 passes against the plan WITHOUT catching this contract mismatch because both formats were documented in different sections. The test-verifier's e2e caught it (AC-21.6 defect).
|
||||
**Lesson** : when a sprint introduces a NEW data path that re-uses an EXISTING endpoint's output, the spec-reviewer should explicitly trace the round-trip in code (matrix output → frontend store → PATCH body → backend validator). Pure spec reading misses contract mismatches that only manifest when a feature wires two existing endpoints together. Add to the spec-reviewer checklist: "for any new feature reusing a prior endpoint, trace the actual data flow A → B → C in code, not just specs in isolation."
|
||||
|
||||
### Engineering — Token system: split themed text vs fixed surface (frontend-builder + design-reviewer)
|
||||
**Context** : First dark mode pass made `--color-ink` themed (light = `#1a1a1a`, dark = `#f9fafb`). That correctly inverts for body text, but inverts WRONG for "dark slabs" (utility strip, footer, modal backdrop) that used `bg-ink` as a fixed-dark surface — they became near-white bars in dark mode. Three separate symptoms, one root cause. Design-reviewer (its first run) caught it.
|
||||
**Lesson** : if a single token (`ink`) plays multiple roles (text + dark surface), split into a themed token (`ink` = text) and a fixed token (`slab` = dark surface that does NOT invert). Same for overlays — `bg-ink/60` for a backdrop inverts; `bg-overlay` or a dedicated `.modal-backdrop` class with `rgba(0,0,0,0.6)` stays correct. Audit token roles BEFORE wiring dark mode, not after.
|
||||
|
||||
### Engineering — Form alignment: structural row layout > class tweaks (frontend-builder × 3 attempts)
|
||||
**Context** : `UsersAdminPage` "Create account" form had labels misaligned by 24px when one cell carried a hint (`≥ 8 characters`) and others didn't, because `items-end` aligned cells to row-bottom. Sprint 2 post-QA "fixed" it with class tweaks. Sprint 4 post-QA reported the bug back. Sprint 4 first attempt added another class tweak (still broken per design-reviewer). Third attempt finally refactored to an explicit 3-row grid (labels / inputs+button / hints) where the browser CAN'T misalign rows of different cells.
|
||||
**Lesson** : after one failed alignment fix via class tweaks, the next attempt MUST be structural (explicit grid rows, or `display: subgrid`, or DOM restructure). Don't try a third class-level tweak on an alignment issue that survived a prior class-level fix — the problem is in the structure, not the styling.
|
||||
|
||||
### Engineering — Hardcoded absolute paths in migration tests (backend-builder × 2 sprints)
|
||||
**Context** : Sprint 3 backend-builder hardcoded `/home/user/.../.claude/worktrees/sprint-3-mitre-matrix/` in the migration round-trip test to load the migration module via `importlib.util.spec_from_file_location`. Sprint 4 the worktree path was renamed and the test broke — backend-builder "fixed" it by hardcoding the NEW worktree path. Code-reviewer caught it with the same finding twice.
|
||||
**Lesson** : any test that loads a sibling file by absolute path is wrong by default. Always derive paths from `__file__` (`Path(__file__).resolve().parent.parent / "migrations" / "versions" / "0004_*.py"`). Backend-builder mental checklist before completing a migration test: grep `/home/user/` and grep `worktrees/` in the diff — anything matching is a smell.
|
||||
|
||||
### Process — Screenshots-mandatory rule has a fragile transport (frontend-builder)
|
||||
**Context** : Sprint 4 first round of post-design-review screenshots, frontend-builder couldn't authenticate Playwright cleanly and delivered 5 screenshots of the login page only — the 4 critical fixes (UsersAdmin alignment, slab in matrix, badge contrast on form, done state dark) were NOT visible on the login page and could not be validated. The DoD says "if dev server can't start, escalate explicitly", but here the dev server DID start — auth just didn't resolve before the screenshot. Builder DID flag it; team-lead bounced back with the exact round-1 auth flow path. Second round worked.
|
||||
**Lesson** : the DoD's screenshots clause needs to cover not just "can the server start" but "can the screenshot capture authenticated UI". For sprint 5+, the brief should remind the builder to use the exact auth pattern that worked in round 1 (`page.goto('/login') → fill → submit → wait for navigation`) OR to seed localStorage with a valid token before `goto`. Don't accept login-page-only screenshots as "screenshots" when the feature being validated lives behind auth.
|
||||
|
||||
### Process — Builders received cross-context inline summaries as re-dispatches (sprint 3 + 4 — pattern)
|
||||
**Context** : When dispatching to one builder I include the other builder's API summary inline (e.g., backend summary embedded in the frontend dispatch as context). Twice now the OTHER builder has interpreted that embedded summary as a re-dispatch to themselves, re-verified their work, and confirmed completion (sprint 3 503-on-unloaded + sprint 4 AC-21.6 round-trip both occurred this way). Net positive — they caught real gaps — but coordination cost.
|
||||
**Lesson** : when embedding another builder's API contract as context for builder B, prefix it with `# REFERENCE — BUILDER A's SUMMARY, INLINE CONTEXT ONLY, NO ACTION NEEDED FROM YOU` (or equivalent). Builders inherit the whole message and don't always parse which sections target them.
|
||||
|
||||
### Engineering — Backend-builder's self-correction via re-dispatch surfaced two real defects (sprint 3 + 4)
|
||||
**Context** : Twice now the backend-builder, after interpreting a frontend dispatch as a "re-dispatch", noticed an unspecified or wrong behavior I'd left in the brief and shipped a fix:
|
||||
- Sprint 3: I left "Bundle non chargé → comportement à décider, je propose 503" ambiguous. Backend-builder picked 503 and added a regression test (`673b25e`).
|
||||
- Sprint 4: backend-builder re-read the AC-21.6 inline brief and reasoned through but their code was correct; the actual fix came after the test-verifier's defect report.
|
||||
**Lesson** : when something in a builder brief reads "à décider" / "TBD" / "we'll see", that's a SPEC HOLE — close it before dispatch, not after. Use AskUserQuestion or take a defensible default and document explicitly which it is.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 3 (closed 2026-05-27)
|
||||
|
||||
### Process — Spec-review 2-pass after team-lead edits (team-lead)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Sprint 4 — UI polish + workflow tightening + dark mode + process hygiene
|
||||
|
||||
**Branche** : `sprint/4-ui-polish`
|
||||
**Statut** : 🟡 DRAFT — 9 décisions arrêtées (3 nouvelles 2026-05-27 + 5 sprint 4 mémoire + 1 du PR helper), spec-reviewer en validation
|
||||
**Statut** : 🟢 SPRINT COMPLET — backend 193/193 + frontend 92/92 + e2e 158/158, PR prête
|
||||
**Base** : `main` @ `27573f5` (sprint 3 mergé via PR #6) + `ba313a3` (carry-over SPEC sprint 3)
|
||||
**Objectif** : absorber les 7 retours QA sprint 3 (UI/UX, workflow, alignement) + livrer le dark mode + durcir le process UI (design-reviewer agent + screenshots mandatory) + automatiser l'ouverture de PR. Pas de hotfix sprint 3 séparé — tout dans sprint 4 (décision user 2026-05-27).
|
||||
|
||||
@@ -73,7 +73,7 @@ L'évolution est tracée dans CHANGELOG.md § Changed sprint 4.
|
||||
- [ ] AC-21.1 : modèle `Simulation` gagne un champ `tactic_ids` (colonne JSON, liste de strings TA-id, défaut `[]`). Séparé de `techniques`.
|
||||
- [ ] AC-21.2 : migration Alembic `0004_simulation_tactic_ids.py` — ADD COLUMN `tactic_ids` (JSON, NOT NULL, default `[]`). Pas besoin de batch pour ADD COLUMN (SQLite natif). Aucun backfill (default suffit).
|
||||
- [ ] AC-21.3 : sérialisation Simulation expose `tactics: [{id, name}]` enrichi à partir de `tactic_ids` (id snapshot + name dérivé du bundle MITRE au runtime, comme pour `techniques`).
|
||||
- [ ] AC-21.4 : `PATCH /api/simulations/<sid>` accepte `{tactic_ids: ["TA0007", ...]}`. Validation : chaque ID doit exister dans `_TACTIC_IDS` (mapping TA-id → short-name, cf §2 Service MITRE). Dedup serveur. ID inconnu → 400. Bundle non chargé → 503.
|
||||
- [ ] AC-21.4 : `PATCH /api/simulations/<sid>` accepte `{tactic_ids: ["TA0007", ...]}`. Validation : chaque ID doit exister dans `_TACTIC_IDS` (mapping TA-id → short-name, cf §2 Service MITRE). Dedup serveur. ID inconnu → 400. **Pas de check `mitre_loaded`** : les TA-ids sont une constante MITRE standard stable hardcodée dans `_TACTIC_IDS` — la validation ne dépend pas du bundle STIX runtime (contrairement aux `technique_ids` qui requièrent le bundle). Donc PATCH `tactic_ids` reste OK même si le bundle est absent (alors que `technique_ids` retourne 503). Spec-aligné avec l'implémentation et les tests post-code-review.
|
||||
- [ ] AC-21.5 : `tactic_ids` est ajouté au gate SOC : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`. SOC envoie → 403. Auto-transition se déclenche aussi si `tactic_ids` non vide.
|
||||
- [ ] AC-21.6 : `MitreMatrixModal` — le header de chaque colonne tactique devient cliquable (toggle de la tactique elle-même). État visuel distinct des techniques sélectionnées. Compteur passe à `N+M selected` (techniques + tactique).
|
||||
- [ ] AC-21.7 : `MitreTechniquesField` — tactiques sélectionnées affichées comme chips distincts (style différencié : `bg-primary text-canvas` au lieu de `bg-primary-soft text-primary-deep`). × pour retirer. Auto-save sur add/remove.
|
||||
@@ -251,7 +251,7 @@ Paths absolus dans le summary final. Si le dev server n'a pas pu tourner, dis-le
|
||||
- `MitreMatrixModal.tsx` : header de tactique cliquable (toggle). État visuel distinct.
|
||||
- Apply renvoie `{techniques, tactics}` au parent.
|
||||
- `MitreTechniquesField.tsx` : tactic chips style différencié `bg-primary text-canvas`. Auto-save.
|
||||
- **PATCH combiné (spec-reviewer fix #4)** : Apply depuis la matrice → UN SEUL PATCH `{technique_ids: [...], tactic_ids: [...]}` (les 2 listes ensemble). Pas 2 PATCH séquentiels (risque de race + risque que le 2nd appel hit le guard done). Remove via × sur un tag → un PATCH avec la liste mise à jour (seulement la dimension qui change : `technique_ids` ou `tactic_ids`). Quick Search select → 1 PATCH `{technique_ids: [...]}` (le picker n'ajoute que des techniques). Toutes les mutations passent par `useUpdateSimulation` en un appel atomique.
|
||||
- **PATCH combiné (spec-reviewer fix #4)** : Apply depuis la matrice → UN SEUL PATCH `{technique_ids: [...], tactic_ids: [...]}` (les 2 listes ensemble). Pas 2 PATCH séquentiels (risque de race + risque que le 2nd appel hit le guard done). Pour les × remove ET les Quick Search adds, l'implémentation finale envoie aussi les 2 listes ensemble (`save({techniques, tactics})`) — fonctionnellement équivalent à un PATCH dimensionnel et plus simple à raisonner (single source of truth = local state). Spec-aligné post-code-review : "always send both dimensions" est la règle, le brief initial "dimension qui change" était over-spec. Toutes les mutations passent par `useUpdateSimulation` en un appel atomique.
|
||||
|
||||
**US-22 — Refonte input MITRE**
|
||||
- `MitreTechniquesField.tsx` :
|
||||
|
||||
Reference in New Issue
Block a user