feat(m7): per-test execution — red/blue zones, evidence pipeline, activity poll

DoD M7 (spec §F5 + §F6 + §F8 + tasks/todo.md M7) covered end-to-end:

Backend
- New migration `91a4e7c6d2f3` adds `mission_tests.last_actor_id` (FK users
  ON DELETE SET NULL) and `ix_mission_tests_updated_at` for the polling query.
- `detection_levels`: 4 default rows seeded at boot, `GET /detection-levels`
  read-only (CRUD lands in M8).
- `mission_tests` service + `missions` API extension:
  - `GET /missions/{id}/tests/{test_id}` — full detail incl. evidence list
  - `PUT  /missions/{id}/tests/{test_id}` — patch red/blue fields with per-field
    perm classification (`mission.write_red_fields` vs `mission.write_blue_fields`)
  - `POST /missions/{id}/tests/{test_id}/transition` — pending↔skipped/blocked
    and pending→executed→reviewed_by_blue (+ undo paths), side-aware perm gate
    that fires *before* idempotency, `executed_at` auto-stamped on the way in
  - `GET  /missions/{id}/activity?since=<ISO>` — drives the 15 s polling badge
- `evidence` service + top-level `/evidence/<id>` API:
  - Streaming upload, SHA256 chunk-by-chunk, 25 MB cap, ext+MIME whitelist
  - Content-addressed storage at ${EVIDENCE_DIR}/<mission>/<test>/<sha256><ext>
  - Atomic `os.replace`, hex-validated SHA path component, root-dir guard
  - Membership-aware (404 on miss/forbidden, no existence leak)
- `/diag/reset` now wipes ${EVIDENCE_DIR}/* in test mode (symlink-safe) and
  re-seeds detection levels as a safety net.

Frontend
- `lib/missions.ts` — M7 types + queryKey factory + state-machine matrix.
- `pages/MissionTestPage.tsx` — two-zone layout: red border (command, output,
  comment, mark-executed + override toggle) and cyan border (detection-level
  select, comment, drag-and-drop evidence dropzone). Last-touched badge polls
  /activity every 15 s, gated on document.visibilityState. Per-field disable
  based on the user's red/blue perms (server stays the arbiter).
- `pages/MissionDetailPage.tsx` — test rows link to the new per-test page.
- `App.tsx` — registers /missions/:id/tests/:testId behind RequireAuth.
- `HomePage.tsx` — hero + roadmap card bumped to M7; next is M8.

Tests
- `backend/tests/test_mission_tests.py` — 27 pytest tests (red/blue field
  gating, state-machine matrix incl. idempotent-side enforcement, executed_at
  override, 24/26 MB upload + SHA256, MIME/ext whitelist, soft-delete hide,
  activity polling with URL-encoded `since`, membership 404 vs admin bypass,
  cross-mission evidence access).
- `e2e/tests/m7-execution.spec.ts` — 5 Playwright tests against the live stack
  (red-only/blue-only API gating, mark-executed + reviewed_by_blue side
  enforcement, 24 MB/26 MB upload + SHA256 round-trip, SPA per-test page save
  + transition, non-member 404 message). afterAll restores stable admin and
  re-syncs MITRE.

Docs
- CHANGELOG.md: M7 section + post-M7 review-pass subsection.
- README.md: status, feature blurb, roadmap, testing-m7 link.
- tasks/testing-m7.md: manual + automated procedure with transition matrix
  and perm-gating table.
- tasks/lessons.md: M7 retrospectives (LogRecord `created` trap, URL-encoded
  query timestamps, perm-before-flush, atomic move, polling visibility gate).

Test count: 133 pytest / 49 Playwright, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-14 08:16:48 +02:00
parent 3c1675966d
commit ed70458d8f
23 changed files with 4273 additions and 19 deletions

View File

@@ -13,6 +13,7 @@ import { HomePage } from '@/pages/HomePage';
import { MitrePage } from '@/pages/MitrePage';
import { LoginPage } from '@/pages/LoginPage';
import { MissionDetailPage } from '@/pages/MissionDetailPage';
import { MissionTestPage } from '@/pages/MissionTestPage';
import { MissionsCreatePage } from '@/pages/MissionsCreatePage';
import { MissionsListPage } from '@/pages/MissionsListPage';
import { ProfilePage } from '@/pages/ProfilePage';
@@ -87,6 +88,14 @@ function App() {
</RequireAuth>
}
/>
<Route
path="/missions/:id/tests/:testId"
element={
<RequireAuth>
<MissionTestPage />
</RequireAuth>
}
/>
<Route
path="/admin/users"
element={

View File

@@ -165,3 +165,134 @@ export const MISSION_STATUS_LABEL: Record<MissionStatus, string> = {
completed: 'Completed',
archived: 'Archived',
};
// =========================================================================== //
// M7 — per-test execution
// =========================================================================== //
export interface DetectionLevel {
id: string;
key: string;
label_fr: string;
label_en: string;
color_token: string;
position: number;
is_default: boolean;
is_system: boolean;
}
export interface DetectionLevelList {
items: DetectionLevel[];
}
export interface MissionTestEvidence {
id: string;
mission_test_id: string;
sha256: string;
mime: string;
size_bytes: number;
original_filename: string;
uploaded_by_user_id: string | null;
uploaded_by_email: string | null;
uploaded_by_display_name: string | null;
uploaded_at: string;
created_at: string;
}
export interface MissionTestDetail {
id: string;
mission_id: string;
scenario_id: string;
position: number;
snapshot_name: string;
snapshot_description: string | null;
snapshot_objective: string | null;
snapshot_procedure_md: string | null;
snapshot_prerequisites_md: string | null;
snapshot_expected_red_md: string | null;
snapshot_expected_blue_md: string | null;
snapshot_opsec_level: MissionOpsecLevel;
snapshot_tags: string[];
snapshot_expected_iocs: string[];
state: MissionTestState;
executed_at: string | null;
executed_at_overridden: boolean;
red_command: string | null;
red_output: string | null;
red_comment_md: string | null;
blue_comment_md: string | null;
detection_level_id: string | null;
detection_level_key: string | null;
last_actor_id: string | null;
last_actor_email: string | null;
last_actor_display_name: string | null;
updated_at: string;
mitre_tags: MissionMitreTag[];
evidence: MissionTestEvidence[];
}
export interface UpdateMissionTestPayload {
red_command?: string | null;
red_output?: string | null;
red_comment_md?: string | null;
blue_comment_md?: string | null;
detection_level_id?: string | null;
executed_at?: string | null;
executed_at_overridden?: boolean;
}
export interface TestTransitionPayload {
target_state: MissionTestState;
}
export interface ActivityEntry {
test_id: string;
scenario_id: string;
state: MissionTestState;
updated_at: string;
last_actor_id: string | null;
last_actor_email: string | null;
last_actor_display_name: string | null;
}
export interface ActivityResponse {
items: ActivityEntry[];
server_time: string;
}
export const missionTestKeys = {
detail: (missionId: string, testId: string) =>
['missions', 'detail', missionId, 'tests', testId] as const,
activity: (missionId: string) => ['missions', missionId, 'activity'] as const,
detectionLevels: () => ['detection-levels'] as const,
};
export const MISSION_TEST_STATE_LABEL: Record<MissionTestState, string> = {
pending: 'Pending',
executed: 'Executed',
reviewed_by_blue: 'Reviewed',
skipped: 'Skipped',
blocked: 'Blocked',
};
export const MISSION_TEST_STATE_ACCENT: Record<
MissionTestState,
'teal' | 'orange' | 'green' | 'rose' | 'red'
> = {
pending: 'teal',
executed: 'orange',
reviewed_by_blue: 'green',
skipped: 'rose',
blocked: 'red',
};
// Front-end mirror of the backend state-machine matrix so the UI only renders
// the transitions the server will accept. Keep this in sync with
// `app/services/mission_tests.py::_VALID_TRANSITIONS`.
export const VALID_TEST_TRANSITIONS: Record<MissionTestState, MissionTestState[]> = {
pending: ['executed', 'skipped', 'blocked'],
executed: ['reviewed_by_blue', 'pending'],
reviewed_by_blue: ['executed'],
skipped: ['pending'],
blocked: ['pending'],
};

View File

@@ -61,7 +61,7 @@ export function HomePage() {
<span className="text-purple">Purple Team Platform</span>
</h1>
<p className="font-mono text-sm font-light text-text-dim mt-2">
Collaborative red &amp; blue test orchestration M6 milestone (Missions &amp; snapshot)
Collaborative red &amp; blue test orchestration M7 milestone (Red &amp; blue execution)
</p>
</header>
<SectionHeader
@@ -141,9 +141,9 @@ export function HomePage() {
<Card accent="purple" title="Roadmap" sub="14 milestones">
<p>
M0 + M1 + M2 + M3 + M4 + M5 + M6 done. Next:{' '}
M0 + M1 + M2 + M3 + M4 + M5 + M6 + M7 done. Next:{' '}
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
M7 Red &amp; blue execution on a mission test
M8 Custom detection-level taxonomy
</code>
.
</p>

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { MarkdownField } from '@/components/MarkdownField';
import { Alert } from '@/components/ui/Alert';
@@ -679,11 +679,19 @@ export function MissionDetailPage() {
{sc.tests.map((t) => (
<tr
key={t.id}
className="border-t border-border/40"
className="border-t border-border/40 hover:bg-bg-base/60"
data-testid={`mission-test-${t.id}`}
>
<td className="py-1 text-text-dim">{t.position + 1}</td>
<td className="py-1 text-text-bright">{t.snapshot_name}</td>
<td className="py-1 text-text-bright">
<Link
to={`/missions/${m.id}/tests/${t.id}`}
className="hover:underline"
data-testid={`mission-test-link-${t.id}`}
>
{t.snapshot_name}
</Link>
</td>
<td className="py-1">
<div className="flex flex-wrap gap-1">
{t.mitre_tags.map((tag) => (

View File

@@ -0,0 +1,750 @@
/**
* Per-test execution page (M7).
*
* Two zones, mirror of the spec §M7:
* - Red zone (red border) — command, output, markdown comment, mark-executed.
* - Blue zone (cyan border) — detection level, markdown comment, evidence dropzone.
*
* State transitions are driven from a small button row in the header. The
* "modified by X Ns ago" indicator polls `/missions/{id}/activity?since=…`
* every 15s while the page is mounted (and the document is visible).
*
* Field-level permissions:
* - Admins always see and write everything.
* - `mission.write_red_fields` enables the red-side form.
* - `mission.write_blue_fields` enables the blue-side form + uploads.
* The server is the ultimate arbiter (PUT/POST will 403 if a side is forbidden);
* the UI just disables inputs the user cannot write to reduce confusion.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { MarkdownField } from '@/components/MarkdownField';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import {
ApiError,
apiDelete,
apiFetch,
apiGet,
apiPost,
apiPut,
} from '@/lib/api';
import { useAuth } from '@/lib/auth';
import {
MISSION_TEST_STATE_ACCENT,
MISSION_TEST_STATE_LABEL,
VALID_TEST_TRANSITIONS,
missionKeys,
missionTestKeys,
type ActivityResponse,
type DetectionLevel,
type DetectionLevelList,
type MissionTestDetail,
type MissionTestEvidence,
type TestTransitionPayload,
type UpdateMissionTestPayload,
} from '@/lib/missions';
const POLL_INTERVAL_MS = 15_000;
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
function formatRelative(iso: string | null | undefined): string {
if (!iso) return 'never';
const then = new Date(iso).getTime();
const now = Date.now();
const seconds = Math.max(0, Math.floor((now - then) / 1000));
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function useMissionTest(missionId: string, testId: string) {
return useQuery({
queryKey: missionTestKeys.detail(missionId, testId),
queryFn: () =>
apiGet<MissionTestDetail>(`/missions/${missionId}/tests/${testId}`),
enabled: !!missionId && !!testId,
});
}
function useDetectionLevels(enabled: boolean) {
return useQuery({
queryKey: missionTestKeys.detectionLevels(),
queryFn: () => apiGet<DetectionLevelList>('/detection-levels'),
enabled,
staleTime: 60_000,
});
}
// --------------------------------------------------------------------------- //
// Activity indicator //
// --------------------------------------------------------------------------- //
function useActivityWatcher(
missionId: string,
testId: string,
onTouched: () => void,
) {
const lastServerTimeRef = useRef<string | null>(null);
const onTouchedRef = useRef(onTouched);
onTouchedRef.current = onTouched;
useEffect(() => {
if (!missionId) return;
let cancelled = false;
async function poll() {
try {
const since = lastServerTimeRef.current;
const url =
`/missions/${missionId}/activity` +
(since ? `?since=${encodeURIComponent(since)}` : '');
const res = await apiGet<ActivityResponse>(url);
if (cancelled) return;
lastServerTimeRef.current = res.server_time;
// We only care about activity on the same test (the badge is local).
if (res.items.some((it) => it.test_id === testId)) {
onTouchedRef.current();
}
} catch {
// Network blips are non-fatal — the badge just doesn't refresh.
}
}
// Prime the timestamp without firing onTouched.
void poll();
const handle = window.setInterval(() => {
if (document.visibilityState === 'visible') void poll();
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(handle);
};
}, [missionId, testId]);
}
// --------------------------------------------------------------------------- //
// Red zone //
// --------------------------------------------------------------------------- //
interface RedZoneProps {
test: MissionTestDetail;
missionId: string;
canWriteRed: boolean;
}
function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
const qc = useQueryClient();
const [command, setCommand] = useState(test.red_command ?? '');
const [output, setOutput] = useState(test.red_output ?? '');
const [comment, setComment] = useState(test.red_comment_md ?? '');
const [override, setOverride] = useState(test.executed_at_overridden);
const [executedAt, setExecutedAt] = useState(test.executed_at ?? '');
const dirty =
command !== (test.red_command ?? '') ||
output !== (test.red_output ?? '') ||
comment !== (test.red_comment_md ?? '') ||
override !== test.executed_at_overridden ||
(override && executedAt !== (test.executed_at ?? ''));
// Sync local state when a refetch lands a newer version of the test
// (concurrent collaboration: blue's edit shouldn't blow away our local
// unsaved changes, but a fresh load should).
useEffect(() => {
setCommand(test.red_command ?? '');
setOutput(test.red_output ?? '');
setComment(test.red_comment_md ?? '');
setOverride(test.executed_at_overridden);
setExecutedAt(test.executed_at ?? '');
// We deliberately reset on every test reload — see comment above.
}, [test]);
const save = useMutation({
mutationFn: (body: UpdateMissionTestPayload) =>
apiPut<MissionTestDetail>(
`/missions/${missionId}/tests/${test.id}`,
body,
),
onSuccess: (next) => {
qc.setQueryData(missionTestKeys.detail(missionId, test.id), next);
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
},
});
function submit() {
const body: UpdateMissionTestPayload = {
red_command: command.trim() || null,
red_output: output.length === 0 ? null : output,
red_comment_md: comment.trim() || null,
};
if (override !== test.executed_at_overridden) {
body.executed_at_overridden = override;
}
if (override) {
const iso = executedAt
? new Date(executedAt).toISOString()
: null;
body.executed_at = iso;
}
save.mutate(body);
}
const apiErr = save.error instanceof ApiError ? save.error : null;
const canOverride =
canWriteRed && (test.state === 'executed' || test.state === 'reviewed_by_blue');
return (
<Card accent="red" className="flex flex-col gap-3" data-testid="red-zone">
<SectionHeader prefix="Red" highlight="Execution" accent="red" />
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
<TextField
label="Command"
value={command}
onChange={(e) => setCommand(e.target.value)}
disabled={!canWriteRed}
className="font-mono"
data-testid="red-command"
placeholder="powershell -enc ..."
/>
<label className="flex flex-col gap-1">
<span className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
Output
</span>
<textarea
value={output}
onChange={(e) => setOutput(e.target.value)}
disabled={!canWriteRed}
rows={8}
className="w-full rounded-md border border-border bg-bg-card p-3 font-mono text-xs text-text"
data-testid="red-output"
placeholder="stdout / stderr capture"
/>
</label>
<MarkdownField
label="Comment"
value={comment}
onChange={setComment}
disabled={!canWriteRed}
data-testid="red-comment"
/>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 font-mono text-2xs text-text-dim">
<input
type="checkbox"
checked={override}
onChange={(e) => setOverride(e.target.checked)}
disabled={!canOverride}
data-testid="red-executed-override"
/>
Override executed-at timestamp
</label>
{override && (
<input
type="datetime-local"
value={
executedAt ? new Date(executedAt).toISOString().slice(0, 16) : ''
}
onChange={(e) => setExecutedAt(e.target.value)}
disabled={!canOverride}
className="rounded-md border border-border bg-bg-card px-2 py-1 font-mono text-2xs text-text"
data-testid="red-executed-at"
/>
)}
{!override && test.executed_at && (
<span className="font-mono text-2xs text-text-dim">
auto-stamped at{' '}
<code className="text-text">{test.executed_at}</code>
</span>
)}
</div>
<div className="flex justify-end">
<Button
accent="red"
onClick={submit}
disabled={!canWriteRed || !dirty || save.isPending}
data-testid="red-save"
>
{save.isPending ? 'Saving…' : 'Save red fields'}
</Button>
</div>
</Card>
);
}
// --------------------------------------------------------------------------- //
// Blue zone //
// --------------------------------------------------------------------------- //
interface BlueZoneProps {
test: MissionTestDetail;
missionId: string;
canWriteBlue: boolean;
detectionLevels: DetectionLevel[];
}
function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZoneProps) {
const qc = useQueryClient();
const [comment, setComment] = useState(test.blue_comment_md ?? '');
const [levelId, setLevelId] = useState(test.detection_level_id ?? '');
const fileInputRef = useRef<HTMLInputElement>(null);
const [dropError, setDropError] = useState<string | null>(null);
const dirty =
comment !== (test.blue_comment_md ?? '') ||
levelId !== (test.detection_level_id ?? '');
useEffect(() => {
setComment(test.blue_comment_md ?? '');
setLevelId(test.detection_level_id ?? '');
}, [test]);
const save = useMutation({
mutationFn: (body: UpdateMissionTestPayload) =>
apiPut<MissionTestDetail>(
`/missions/${missionId}/tests/${test.id}`,
body,
),
onSuccess: (next) => {
qc.setQueryData(missionTestKeys.detail(missionId, test.id), next);
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
},
});
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData();
fd.append('file', file);
const res = await apiFetch(
`/missions/${missionId}/tests/${test.id}/evidence`,
{ method: 'POST', body: fd },
);
if (!res.ok) {
const body = await res
.json()
.catch(() => ({ error: 'upload_failed' as const }));
throw new ApiError(res.status, body);
}
return (await res.json()) as MissionTestEvidence;
},
onSuccess: () => {
qc.invalidateQueries({
queryKey: missionTestKeys.detail(missionId, test.id),
});
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
},
});
const remove = useMutation({
mutationFn: (evidenceId: string) =>
apiDelete<{ ok: boolean }>(`/evidence/${evidenceId}`),
onSuccess: () => {
qc.invalidateQueries({
queryKey: missionTestKeys.detail(missionId, test.id),
});
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
},
});
function submit() {
save.mutate({
blue_comment_md: comment.trim() || null,
detection_level_id: levelId || null,
});
}
function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
setDropError(null);
// Per spec §M7, max 25 MB per file is server-enforced; this is just a
// friendly UX guardrail so the user does not waste a roundtrip.
for (const f of Array.from(files)) {
if (f.size > 25 * 1024 * 1024) {
setDropError(`"${f.name}" exceeds the 25 MB limit.`);
continue;
}
upload.mutate(f);
}
}
const apiErr =
save.error instanceof ApiError
? save.error
: upload.error instanceof ApiError
? upload.error
: remove.error instanceof ApiError
? remove.error
: null;
return (
<Card accent="cyan" className="flex flex-col gap-3" data-testid="blue-zone">
<SectionHeader prefix="Blue" highlight="Detection" accent="cyan" />
{apiErr && (
<Alert accent="red">
{apiErr.message}
{typeof apiErr.payload === 'object' &&
apiErr.payload &&
'message' in (apiErr.payload as Record<string, unknown>)
? `${(apiErr.payload as { message?: string }).message}`
: ''}
</Alert>
)}
<label className="flex flex-col gap-1">
<span className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
Detection level
</span>
<select
value={levelId}
onChange={(e) => setLevelId(e.target.value)}
disabled={!canWriteBlue}
className="rounded-md border border-border bg-bg-card px-2 py-2 font-mono text-xs text-text"
data-testid="blue-detection-level"
>
<option value=""> none </option>
{detectionLevels.map((lvl) => (
<option key={lvl.id} value={lvl.id}>
{lvl.label_en} ({lvl.key})
</option>
))}
</select>
</label>
<MarkdownField
label="Comment"
value={comment}
onChange={setComment}
disabled={!canWriteBlue}
data-testid="blue-comment"
/>
<div className="flex justify-end">
<Button
accent="cyan"
onClick={submit}
disabled={!canWriteBlue || !dirty || save.isPending}
data-testid="blue-save"
>
{save.isPending ? 'Saving…' : 'Save blue fields'}
</Button>
</div>
<div className="flex flex-col gap-2">
<p className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
Evidence
</p>
<div
className={`rounded-md border-2 border-dashed p-4 text-center ${
canWriteBlue ? 'border-cyan/60' : 'border-border'
}`}
onDragOver={(e) => {
if (!canWriteBlue) return;
e.preventDefault();
}}
onDrop={(e) => {
if (!canWriteBlue) return;
e.preventDefault();
handleFiles(e.dataTransfer.files);
}}
data-testid="evidence-dropzone"
>
<p className="font-mono text-2xs text-text-dim">
{canWriteBlue
? 'Drag files here or click to pick (≤ 25 MB each)'
: 'Read-only — no upload permission'}
</p>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
data-testid="evidence-file-input"
/>
{canWriteBlue && (
<Button
variant="ghost"
accent="cyan"
className="mt-2"
onClick={() => fileInputRef.current?.click()}
disabled={upload.isPending}
data-testid="evidence-pick"
>
{upload.isPending ? 'Uploading…' : 'Pick files'}
</Button>
)}
{dropError && (
<p className="mt-2 font-mono text-2xs text-red">{dropError}</p>
)}
</div>
{test.evidence.length === 0 ? (
<p className="font-mono text-2xs text-text-dim">No evidence yet.</p>
) : (
<table className="w-full font-mono text-2xs">
<thead>
<tr className="text-text-dim uppercase tracking-wider2">
<th className="text-left py-1">File</th>
<th className="text-left py-1">Size</th>
<th className="text-left py-1">By</th>
<th className="text-left py-1">SHA256</th>
<th />
</tr>
</thead>
<tbody data-testid="evidence-list">
{test.evidence.map((ev) => (
<tr
key={ev.id}
className="border-t border-border/40"
data-testid={`evidence-row-${ev.id}`}
>
<td className="py-1 text-text-bright">{ev.original_filename}</td>
<td className="py-1">{formatBytes(ev.size_bytes)}</td>
<td className="py-1 text-text">
{ev.uploaded_by_email ?? '<deleted>'}
</td>
<td className="py-1">
<code className="text-text-dim">{ev.sha256.slice(0, 12)}</code>
</td>
<td className="py-1 text-right">
<a
href={`/api/v1/evidence/${ev.id}?download=true`}
target="_blank"
rel="noreferrer"
className="text-cyan font-mono text-2xs"
data-testid={`evidence-download-${ev.id}`}
>
download
</a>
{canWriteBlue && (
<button
type="button"
onClick={() => {
if (
window.confirm(
`Soft-delete evidence "${ev.original_filename}"?`,
)
) {
remove.mutate(ev.id);
}
}}
className="ml-2 text-rose font-mono text-2xs"
data-testid={`evidence-delete-${ev.id}`}
disabled={remove.isPending}
>
delete
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</Card>
);
}
// --------------------------------------------------------------------------- //
// Top-level page //
// --------------------------------------------------------------------------- //
export function MissionTestPage() {
const params = useParams<{ id: string; testId: string }>();
const missionId = params.id ?? '';
const testId = params.testId ?? '';
const navigate = useNavigate();
const { state } = useAuth();
const qc = useQueryClient();
const detail = useMissionTest(missionId, testId);
const levels = useDetectionLevels(
!!state.user &&
(state.user.is_admin ||
state.user.permissions.includes('detection_level.read')),
);
const onActivityTouched = useCallback(() => {
qc.invalidateQueries({
queryKey: missionTestKeys.detail(missionId, testId),
});
}, [qc, missionId, testId]);
useActivityWatcher(missionId, testId, onActivityTouched);
const transition = useMutation({
mutationFn: (body: TestTransitionPayload) =>
apiPost<MissionTestDetail>(
`/missions/${missionId}/tests/${testId}/transition`,
body,
),
onSuccess: (next) => {
qc.setQueryData(missionTestKeys.detail(missionId, testId), next);
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
},
});
const test = detail.data;
const perms = useMemo(() => {
const isAdmin = !!state.user?.is_admin;
const codes = state.user?.permissions ?? [];
return {
isAdmin,
canWriteRed: isAdmin || codes.includes('mission.write_red_fields'),
canWriteBlue: isAdmin || codes.includes('mission.write_blue_fields'),
};
}, [state.user]);
if (!missionId || !testId) {
return (
<Alert accent="red">Missing mission or test id in URL.</Alert>
);
}
if (detail.isLoading) {
return <p className="font-mono text-xs text-text-dim">Loading</p>;
}
if (detail.error instanceof ApiError && detail.error.status === 404) {
return (
<Alert accent="rose">
Mission test not found, or you are not a member of this mission.
</Alert>
);
}
if (!test) {
return <Alert accent="red">Failed to load mission test.</Alert>;
}
const allowedTransitions = VALID_TEST_TRANSITIONS[test.state] ?? [];
const transitionErr =
transition.error instanceof ApiError ? transition.error : null;
return (
<div className="flex flex-col gap-4" data-testid="mission-test-page">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<Link
to={`/missions/${missionId}`}
className="font-mono text-2xs uppercase tracking-wider2 text-text-dim hover:text-text-bright"
data-testid="back-to-mission"
>
Back to mission
</Link>
<h1 className="mt-1 font-mono text-xl font-bold text-text-bright">
{test.snapshot_name}
</h1>
<p className="font-mono text-2xs text-text-dim">
Last touched{' '}
<span data-testid="last-actor-rel">{formatRelative(test.updated_at)}</span>
{test.last_actor_email
? ` by ${test.last_actor_display_name ?? test.last_actor_email}`
: ''}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span data-testid="state-pill">
<Tag accent={MISSION_TEST_STATE_ACCENT[test.state]}>
{MISSION_TEST_STATE_LABEL[test.state]}
</Tag>
</span>
{allowedTransitions.map((target) => (
<Button
key={target}
accent={MISSION_TEST_STATE_ACCENT[target]}
variant="ghost"
onClick={() => transition.mutate({ target_state: target })}
disabled={transition.isPending}
data-testid={`transition-${target}`}
>
{MISSION_TEST_STATE_LABEL[target]}
</Button>
))}
</div>
</div>
{transitionErr && (
<Alert accent="red">
{transitionErr.payload &&
typeof transitionErr.payload === 'object' &&
'message' in (transitionErr.payload as Record<string, unknown>)
? `${transitionErr.message}${(transitionErr.payload as { message?: string }).message}`
: transitionErr.message}
</Alert>
)}
<Card className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{test.mitre_tags.map((tag) => (
<Tag accent="cyan" key={`${tag.kind}-${tag.external_id}`}>
{tag.external_id} {tag.name}
</Tag>
))}
<Tag accent="teal">OPSEC: {test.snapshot_opsec_level}</Tag>
</div>
{test.snapshot_objective && (
<p className="font-mono text-xs text-text">
<span className="text-text-dim">Objective: </span>
{test.snapshot_objective}
</p>
)}
{test.snapshot_procedure_md && (
<details className="font-mono text-2xs text-text-dim" data-testid="procedure">
<summary className="cursor-pointer text-text">Procedure</summary>
<pre className="mt-1 whitespace-pre-wrap text-text">
{test.snapshot_procedure_md}
</pre>
</details>
)}
{test.snapshot_expected_red_md && (
<details className="font-mono text-2xs text-text-dim">
<summary className="cursor-pointer text-text">Expected (red)</summary>
<pre className="mt-1 whitespace-pre-wrap text-text">
{test.snapshot_expected_red_md}
</pre>
</details>
)}
{test.snapshot_expected_blue_md && (
<details className="font-mono text-2xs text-text-dim">
<summary className="cursor-pointer text-text">Expected (blue)</summary>
<pre className="mt-1 whitespace-pre-wrap text-text">
{test.snapshot_expected_blue_md}
</pre>
</details>
)}
</Card>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<RedZone test={test} missionId={missionId} canWriteRed={perms.canWriteRed} />
<BlueZone
test={test}
missionId={missionId}
canWriteBlue={perms.canWriteBlue}
detectionLevels={levels.data?.items ?? []}
/>
</div>
{/* Navigation hint when state moves to archived parent — out of M7 scope, but
we still let the page render. */}
{detail.error instanceof ApiError &&
detail.error.status >= 400 &&
detail.error.status < 500 && (
<Button accent="cyan" onClick={() => navigate(`/missions/${missionId}`)}>
Back to mission
</Button>
)}
</div>
);
}