Files
Metamorph/frontend/src/lib/missions.ts

163 lines
4.2 KiB
TypeScript
Raw Normal View History

feat(m6): missions + snapshot CRUD, membership visibility, status state machine Adds the mission layer that materialises template snapshots, plus the SPA list / 3-step wizard / detail page. Backend: - app/services/missions.py — create_mission snapshots scenarios, tests, MITRE tags in a 4-query write; list/get apply a non-admin membership filter that collapses to 404 (no existence leak); status state machine enforces draft → in_progress → completed → archived with archived as a sink; the non-admin creator is auto-added as role_hint='red' to retain visibility. - app/api/missions.py — 8 endpoints (list, get, create, update, add scenarios, set members, transition, soft-delete) with strict pydantic schemas. The transition endpoint splits the perm gate manually so archive requires mission.archive while other targets use mission.update. - app/api/users.py — new GET /users/roster returning (id, email, display_name) only, gated by user.read OR mission.create OR mission.update — lets non-admin wizard users see assignable peers without exposing the admin /users payload. - app/api/diag.py — /diag/reset truncates the mission_* tables before the template tables because the source_*_template_id FKs are ON DELETE SET NULL, which is cheaper to short-circuit by removing the children first. Frontend: - lib/missions.ts — typed client, queryKey factory, status accent map. - pages/MissionsListPage.tsx — list cards with status accent + filters (q, client, status). - pages/MissionsCreatePage.tsx — 3-step wizard (meta → scenarios → members) with member roster fed by /users/roster. - pages/MissionDetailPage.tsx — header + transition buttons (legal next states only) + Tests/Members/Synthesis/Export tabs. - Routes + nav entry (visible to anyone with mission.read or admin). Tests: - backend/tests/test_missions.py — 22 pytest covering snapshot fidelity, MITRE propagation, membership visibility, transition state machine, perm gating, member set replace, append scenarios, soft-delete, partial update, inverted-date rejection. - e2e/tests/m6-missions.spec.ts — 5 Playwright (snapshot freezing, non-admin visibility, status transitions + 409, SPA wizard end-to-end, list filter). Docs: - CHANGELOG, tasks/testing-m6.md, tasks/lessons.md (snapshot tradeoffs, membership=404 pattern, /diag/reset order, auto-creator add). - README + tasks/todo.md updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:07:32 +02:00
/**
* Mission types + query-key factory.
*
* A mission is a *snapshot* of one or more scenario templates: the backend
* copies template fields into mission_* tables at creation time, and template
* edits after that point do not propagate. Types here mirror the server-side
* dataclasses in `app/services/missions.py`.
*/
export type MissionStatus = 'draft' | 'in_progress' | 'completed' | 'archived';
export type MissionRoleHint = 'red' | 'blue';
export type MissionTestState =
| 'pending'
| 'executed'
| 'reviewed_by_blue'
| 'skipped'
| 'blocked';
export type MissionVisibilityMode = 'whitebox' | 'titles_only' | 'executed_only';
export type MissionMitreKind = 'tactic' | 'technique' | 'subtechnique';
export type MissionOpsecLevel = 'low' | 'medium' | 'high';
export interface MissionMember {
user_id: string;
user_email: string;
user_display_name: string | null;
role_hint: MissionRoleHint;
}
export interface MissionMitreTag {
kind: MissionMitreKind;
external_id: string;
name: string;
url: string | null;
}
export interface MissionTest {
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;
mitre_tags: MissionMitreTag[];
source_test_template_id: string | null;
}
export interface MissionScenario {
id: string;
position: number;
snapshot_name: string;
snapshot_description: string | null;
tests: MissionTest[];
source_scenario_template_id: string | null;
}
export interface MissionListItem {
id: string;
name: string;
client_target: string | null;
date_start: string | null;
date_end: string | null;
status: MissionStatus;
description_md: string | null;
visibility_mode: MissionVisibilityMode;
scenarios_count: number;
tests_count: number;
members_count: number;
deleted_at: string | null;
created_at: string;
updated_at: string;
}
export interface Mission extends MissionListItem {
scenarios: MissionScenario[];
members: MissionMember[];
}
export interface MissionListResponse {
items: MissionListItem[];
total: number;
limit: number;
offset: number;
}
export interface MissionFilters {
q?: string;
status?: MissionStatus | '';
client?: string;
}
export interface MemberPayload {
user_id: string;
role_hint: MissionRoleHint;
}
export interface CreateMissionPayload {
name: string;
client_target?: string | null;
date_start?: string | null;
date_end?: string | null;
description_md?: string | null;
scenario_template_ids?: string[];
members?: MemberPayload[];
}
export interface UpdateMissionPayload {
name?: string;
client_target?: string | null;
date_start?: string | null;
date_end?: string | null;
description_md?: string | null;
}
export interface AddScenariosPayload {
scenario_template_ids: string[];
}
export interface SetMembersPayload {
members: MemberPayload[];
}
export interface TransitionPayload {
status: MissionStatus;
}
export const missionKeys = {
list: (filters?: MissionFilters) => ['missions', 'list', filters ?? {}] as const,
detail: (id: string) => ['missions', 'detail', id] as const,
};
export function buildMissionQueryString(filters: MissionFilters | undefined): string {
if (!filters) return '';
const params = new URLSearchParams();
if (filters.q) params.set('q', filters.q);
if (filters.status) params.set('status', filters.status);
if (filters.client) params.set('client', filters.client);
const s = params.toString();
return s ? `?${s}` : '';
}
export const MISSION_STATUS_ACCENT: Record<MissionStatus, 'cyan' | 'orange' | 'green' | 'teal'> = {
draft: 'cyan',
in_progress: 'orange',
completed: 'green',
archived: 'teal',
};
export const MISSION_STATUS_LABEL: Record<MissionStatus, string> = {
draft: 'Draft',
in_progress: 'In Progress',
completed: 'Completed',
archived: 'Archived',
};