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>
2026-05-14 08:16:48 +02:00
|
|
|
/**
|
|
|
|
|
* 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`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 17:05:48 +02:00
|
|
|
/**
|
|
|
|
|
* Convert a UTC ISO datetime to the wall-clock `YYYY-MM-DDTHH:MM` string that
|
|
|
|
|
* `<input type="datetime-local">` expects (and displays) in the operator's
|
|
|
|
|
* timezone. The naive `new Date(iso).toISOString().slice(0, 16)` shortcut
|
|
|
|
|
* round-trips through UTC and silently shifts the time by the local offset
|
|
|
|
|
* every time the user types, making the hour un-editable in any non-UTC TZ.
|
|
|
|
|
*/
|
|
|
|
|
function isoToLocalInputValue(iso: string | null | undefined): string {
|
|
|
|
|
if (!iso) return '';
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
if (Number.isNaN(d.getTime())) return '';
|
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
|
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Parse a `datetime-local` input value (already in wall-clock local time)
|
|
|
|
|
* back into a UTC ISO string. Returns null if the input is empty/invalid. */
|
|
|
|
|
function localInputValueToIso(local: string): string | null {
|
|
|
|
|
if (!local) return null;
|
|
|
|
|
const d = new Date(local);
|
|
|
|
|
if (Number.isNaN(d.getTime())) return null;
|
|
|
|
|
return d.toISOString();
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-05-14 08:16:48 +02:00
|
|
|
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);
|
2026-05-14 17:05:48 +02:00
|
|
|
// `executedAtLocal` is the raw `YYYY-MM-DDTHH:MM` string the datetime-local
|
|
|
|
|
// input speaks; we only convert to/from UTC ISO at the boundaries
|
|
|
|
|
// (initial sync + submit). Doing the round-trip on every keystroke shifted
|
|
|
|
|
// the hour by the local TZ offset and made the time field uneditable in
|
|
|
|
|
// any non-UTC zone (cf. tasks/lessons.md M7 fix).
|
|
|
|
|
const [executedAtLocal, setExecutedAtLocal] = useState(
|
|
|
|
|
isoToLocalInputValue(test.executed_at),
|
|
|
|
|
);
|
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>
2026-05-14 08:16:48 +02:00
|
|
|
const dirty =
|
|
|
|
|
command !== (test.red_command ?? '') ||
|
|
|
|
|
output !== (test.red_output ?? '') ||
|
|
|
|
|
comment !== (test.red_comment_md ?? '') ||
|
|
|
|
|
override !== test.executed_at_overridden ||
|
2026-05-14 17:05:48 +02:00
|
|
|
(override &&
|
|
|
|
|
executedAtLocal !== isoToLocalInputValue(test.executed_at));
|
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>
2026-05-14 08:16:48 +02:00
|
|
|
|
2026-05-14 17:05:48 +02:00
|
|
|
// Sync local state only when the *identity* of the test changes (route
|
|
|
|
|
// change). Polling refetches return a new object reference for the same
|
|
|
|
|
// test_id; resetting on those would wipe whatever the user is mid-typing.
|
|
|
|
|
// Successful saves push the new test through `setQueryData`, which is fine
|
|
|
|
|
// — the saved fields already match local state.
|
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>
2026-05-14 08:16:48 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
setCommand(test.red_command ?? '');
|
|
|
|
|
setOutput(test.red_output ?? '');
|
|
|
|
|
setComment(test.red_comment_md ?? '');
|
|
|
|
|
setOverride(test.executed_at_overridden);
|
2026-05-14 17:05:48 +02:00
|
|
|
setExecutedAtLocal(isoToLocalInputValue(test.executed_at));
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [test.id]);
|
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>
2026-05-14 08:16:48 +02:00
|
|
|
|
|
|
|
|
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) {
|
2026-05-14 17:05:48 +02:00
|
|
|
body.executed_at = localInputValueToIso(executedAtLocal);
|
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>
2026-05-14 08:16:48 +02:00
|
|
|
}
|
|
|
|
|
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"
|
2026-05-14 17:05:48 +02:00
|
|
|
value={executedAtLocal}
|
|
|
|
|
onChange={(e) => setExecutedAtLocal(e.target.value)}
|
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>
2026-05-14 08:16:48 +02:00
|
|
|
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 ?? '');
|
|
|
|
|
|
2026-05-14 17:05:48 +02:00
|
|
|
// Sync from server only on test-identity change (route nav), not on every
|
|
|
|
|
// polling refetch — see RedZone for the rationale.
|
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>
2026-05-14 08:16:48 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
setComment(test.blue_comment_md ?? '');
|
|
|
|
|
setLevelId(test.detection_level_id ?? '');
|
2026-05-14 17:05:48 +02:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [test.id]);
|
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>
2026-05-14 08:16:48 +02:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|