feat(phase-13): update frontend types and API clients for Red/Blue workflow (T-113, T-114)

T-113: Rewrite models.ts with v2 types - TestState now includes red_executing/blue_evaluating, add TeamSide, ValidationStatus, TestTemplate, TestTemplateSummary, TestTimelineEntry types, RED_EDITABLE_STATES/BLUE_EDITABLE_STATES constants, and dual validation fields on Test interface. Remove old validated_by/validated_at references from TestDetailPage and techniques API.

T-114: Rewrite tests.ts API client with 16 functions covering full Red/Blue workflow (createTestFromTemplate, updateTestRed/Blue, startExecution, submitRed/Blue, validateAsRedLead/BlueLead, reopenTest, getTestTimeline). Rewrite evidence.ts with team parameter on upload/list and new deleteEvidence. Create test-templates.ts with getTemplates, getTemplateById, getTemplatesByTechnique, createTemplate, importAtomicTests.
This commit is contained in:
2026-02-09 10:57:48 +01:00
parent 9d7832c571
commit d660bceeb4
6 changed files with 447 additions and 68 deletions

View File

@@ -1,30 +1,76 @@
import client from "./client"; import client from "./client";
import type { Evidence, TeamSide } from "../types/models";
export interface EvidenceOut { // ── Response type (with download URL) ──────────────────────────────
id: string;
test_id: string; export interface EvidenceOut extends Evidence {
file_name: string;
sha256_hash: string;
uploaded_by: string | null;
uploaded_at: string;
download_url: string; download_url: string;
} }
/** Upload evidence file for a test. */ // ── Upload ─────────────────────────────────────────────────────────
export async function uploadEvidence(testId: string, file: File): Promise<EvidenceOut> {
/** Upload an evidence file for the given test.
*
* The ``team`` field is sent as form data alongside the file so the
* backend can enforce Red/Blue access control.
*/
export async function uploadEvidence(
testId: string,
file: File,
team: TeamSide,
notes?: string,
): Promise<EvidenceOut> {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("team", team);
if (notes) {
formData.append("notes", notes);
}
const { data } = await client.post<EvidenceOut>(`/tests/${testId}/evidence`, formData, { const { data } = await client.post<EvidenceOut>(
headers: { `/tests/${testId}/evidence`,
"Content-Type": "multipart/form-data", formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
}, },
}); );
return data; return data;
} }
/** Get evidence metadata with download URL. */ // ── List ───────────────────────────────────────────────────────────
/** List evidences for a test, optionally filtered by team. */
export async function getTestEvidences(
testId: string,
team?: TeamSide,
): Promise<EvidenceOut[]> {
const params = new URLSearchParams();
if (team) params.append("team", team);
const { data } = await client.get<EvidenceOut[]>(
`/tests/${testId}/evidence${params.toString() ? `?${params}` : ""}`,
);
return data;
}
// ── Detail ─────────────────────────────────────────────────────────
/** Get evidence metadata with presigned download URL. */
export async function getEvidence(evidenceId: string): Promise<EvidenceOut> { export async function getEvidence(evidenceId: string): Promise<EvidenceOut> {
const { data } = await client.get<EvidenceOut>(`/evidence/${evidenceId}`); const { data } = await client.get<EvidenceOut>(`/evidence/${evidenceId}`);
return data; return data;
} }
// ── Delete ─────────────────────────────────────────────────────────
/** Delete an evidence record (only in editable states). */
export async function deleteEvidence(
evidenceId: string,
): Promise<{ detail: string }> {
const { data } = await client.delete<{ detail: string }>(
`/evidence/${evidenceId}`,
);
return data;
}

View File

@@ -1,5 +1,5 @@
import client from "./client"; import client from "./client";
import type { Technique, TechniqueStatus } from "../types/models"; import type { Technique, TechniqueStatus, Test, IntelItem } from "../types/models";
/** Summary representation used in list endpoints. */ /** Summary representation used in list endpoints. */
export interface TechniqueSummary { export interface TechniqueSummary {
@@ -13,31 +13,8 @@ export interface TechniqueSummary {
/** Extended technique with tests for detail view. */ /** Extended technique with tests for detail view. */
export interface TechniqueWithTests extends Technique { export interface TechniqueWithTests extends Technique {
tests?: Array<{ tests?: Test[];
id: string; intel_items?: IntelItem[];
technique_id: string;
name: string;
description: string | null;
platform: string | null;
procedure_text: string | null;
tool_used: string | null;
execution_date: string | null;
created_by: string | null;
result: "detected" | "not_detected" | "partially_detected" | null;
state: "draft" | "in_review" | "validated" | "rejected";
validated_by: string | null;
validated_at: string | null;
created_at: string;
}>;
intel_items?: Array<{
id: string;
technique_id: string | null;
url: string;
title: string | null;
source: string | null;
detected_at: string;
reviewed: boolean;
}>;
} }
export interface TechniqueFilters { export interface TechniqueFilters {

View File

@@ -0,0 +1,106 @@
import client from "./client";
import type { TestTemplate, TestTemplateSummary } from "../types/models";
// ── Filters ────────────────────────────────────────────────────────
export interface TemplateFilters {
source?: string;
platform?: string;
severity?: string;
mitre_technique_id?: string;
search?: string;
offset?: number;
limit?: number;
}
// ── Create payload ─────────────────────────────────────────────────
export interface CreateTemplatePayload {
mitre_technique_id: string;
name: string;
description?: string;
source?: string;
source_url?: string;
attack_procedure?: string;
expected_detection?: string;
platform?: string;
tool_suggested?: string;
severity?: string;
atomic_test_id?: string;
}
// ── Import response ────────────────────────────────────────────────
export interface ImportAtomicResponse {
message: string;
imported: number;
skipped: number;
total_parsed: number;
}
// ── List (paginated, filtered) ─────────────────────────────────────
/** Fetch a paginated, filtered list of test templates. */
export async function getTemplates(
filters?: TemplateFilters,
): Promise<TestTemplateSummary[]> {
const params = new URLSearchParams();
if (filters?.source) params.append("source", filters.source);
if (filters?.platform) params.append("platform", filters.platform);
if (filters?.severity) params.append("severity", filters.severity);
if (filters?.mitre_technique_id)
params.append("mitre_technique_id", filters.mitre_technique_id);
if (filters?.search) params.append("search", filters.search);
if (filters?.offset !== undefined)
params.append("offset", String(filters.offset));
if (filters?.limit !== undefined)
params.append("limit", String(filters.limit));
const { data } = await client.get<TestTemplateSummary[]>(
`/test-templates${params.toString() ? `?${params}` : ""}`,
);
return data;
}
// ── Detail ─────────────────────────────────────────────────────────
/** Fetch a single test template by ID. */
export async function getTemplateById(id: string): Promise<TestTemplate> {
const { data } = await client.get<TestTemplate>(`/test-templates/${id}`);
return data;
}
// ── By technique ───────────────────────────────────────────────────
/** Fetch all templates mapped to a specific MITRE technique. */
export async function getTemplatesByTechnique(
mitreId: string,
): Promise<TestTemplateSummary[]> {
const { data } = await client.get<TestTemplateSummary[]>(
`/test-templates/by-technique/${mitreId}`,
);
return data;
}
// ── Create (admin) ─────────────────────────────────────────────────
/** Create a custom test template. Admin only. */
export async function createTemplate(
payload: CreateTemplatePayload,
): Promise<TestTemplate> {
const { data } = await client.post<TestTemplate>(
"/test-templates",
payload,
);
return data;
}
// ── Import Atomic Red Team ─────────────────────────────────────────
/** Trigger Atomic Red Team import. Admin only. */
export async function importAtomicTests(): Promise<ImportAtomicResponse> {
const { data } = await client.post<ImportAtomicResponse>(
"/system/import-atomic-tests",
);
return data;
}

View File

@@ -1,5 +1,12 @@
import client from "./client"; import client from "./client";
import type { Test, TestResult } from "../types/models"; import type {
Test,
TestResult,
TestState,
TestTimelineEntry,
} from "../types/models";
// ── Payloads ───────────────────────────────────────────────────────
export interface TestCreatePayload { export interface TestCreatePayload {
technique_id: string; technique_id: string;
@@ -19,21 +26,57 @@ export interface TestUpdatePayload {
result?: TestResult; result?: TestResult;
} }
export interface RedUpdatePayload {
name?: string;
description?: string;
procedure_text?: string;
tool_used?: string;
attack_success?: boolean;
red_summary?: string;
}
export interface BlueUpdatePayload {
detection_result?: TestResult;
blue_summary?: string;
}
export interface RedValidationPayload {
red_validation_status: "approved" | "rejected";
red_validation_notes?: string;
}
export interface BlueValidationPayload {
blue_validation_status: "approved" | "rejected";
blue_validation_notes?: string;
}
/** Legacy payload — kept for backwards compat. */
export interface TestValidatePayload { export interface TestValidatePayload {
result: TestResult; result: TestResult;
comments?: string; comments?: string;
} }
export interface TestWithEvidences extends Test { export interface TestListFilters {
evidences?: Array<{ state?: TestState;
id: string; technique_id?: string;
test_id: string; offset?: number;
file_name: string; limit?: number;
sha256_hash: string; }
uploaded_by: string | null;
uploaded_at: string; // ── CRUD ───────────────────────────────────────────────────────────
download_url?: string;
}>; /** List tests with optional filters. */
export async function getTests(filters?: TestListFilters): Promise<Test[]> {
const params = new URLSearchParams();
if (filters?.state) params.append("state", filters.state);
if (filters?.technique_id) params.append("technique_id", filters.technique_id);
if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
const { data } = await client.get<Test[]>(
`/tests${params.toString() ? `?${params}` : ""}`,
);
return data;
} }
/** Create a new test. */ /** Create a new test. */
@@ -42,25 +85,134 @@ export async function createTest(payload: TestCreatePayload): Promise<Test> {
return data; return data;
} }
/** Get test by ID with evidences. */ /** Create a test from an existing template. */
export async function getTestById(testId: string): Promise<TestWithEvidences> { export async function createTestFromTemplate(
const { data } = await client.get<TestWithEvidences>(`/tests/${testId}`); templateId: string,
techniqueId: string,
): Promise<Test> {
const { data } = await client.post<Test>("/tests/from-template", {
template_id: templateId,
technique_id: techniqueId,
});
return data;
}
/** Get test by ID (with evidences). */
export async function getTestById(testId: string): Promise<Test> {
const { data } = await client.get<Test>(`/tests/${testId}`);
return data; return data;
} }
/** Update a test (only draft/rejected). */ /** Update a test (only draft/rejected). */
export async function updateTest(testId: string, payload: TestUpdatePayload): Promise<Test> { export async function updateTest(
testId: string,
payload: TestUpdatePayload,
): Promise<Test> {
const { data } = await client.patch<Test>(`/tests/${testId}`, payload); const { data } = await client.patch<Test>(`/tests/${testId}`, payload);
return data; return data;
} }
/** Validate a test. */ // ── Red Team ───────────────────────────────────────────────────────
export async function validateTest(testId: string, payload: TestValidatePayload): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/validate`, payload); /** Red Team updates their fields (draft, red_executing). */
export async function updateTestRed(
testId: string,
payload: RedUpdatePayload,
): Promise<Test> {
const { data } = await client.patch<Test>(`/tests/${testId}/red`, payload);
return data; return data;
} }
/** Reject a test. */ /** Move test from draft → red_executing. */
export async function startExecution(testId: string): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/start-execution`);
return data;
}
/** Red Team finalises — red_executing → blue_evaluating. */
export async function submitRedEvidence(testId: string): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/submit-red`);
return data;
}
// ── Blue Team ──────────────────────────────────────────────────────
/** Blue Team updates their fields (blue_evaluating only). */
export async function updateTestBlue(
testId: string,
payload: BlueUpdatePayload,
): Promise<Test> {
const { data } = await client.patch<Test>(`/tests/${testId}/blue`, payload);
return data;
}
/** Blue Team finalises — blue_evaluating → in_review. */
export async function submitBlueEvidence(testId: string): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/submit-blue`);
return data;
}
// ── Lead Validation ────────────────────────────────────────────────
/** Red Lead approves/rejects the red side. */
export async function validateAsRedLead(
testId: string,
payload: RedValidationPayload,
): Promise<Test> {
const { data } = await client.post<Test>(
`/tests/${testId}/validate-red`,
payload,
);
return data;
}
/** Blue Lead approves/rejects the blue side. */
export async function validateAsBlueLead(
testId: string,
payload: BlueValidationPayload,
): Promise<Test> {
const { data } = await client.post<Test>(
`/tests/${testId}/validate-blue`,
payload,
);
return data;
}
// ── Reopen ─────────────────────────────────────────────────────────
/** Reopen a rejected test — moves back to draft. */
export async function reopenTest(testId: string): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/reopen`);
return data;
}
// ── Timeline ───────────────────────────────────────────────────────
/** Get the audit-log timeline for a test. */
export async function getTestTimeline(
testId: string,
): Promise<TestTimelineEntry[]> {
const { data } = await client.get<TestTimelineEntry[]>(
`/tests/${testId}/timeline`,
);
return data;
}
// ── Legacy (kept for backwards compat) ─────────────────────────────
/** Validate a test (legacy endpoint). */
export async function validateTest(
testId: string,
payload: TestValidatePayload,
): Promise<Test> {
const { data } = await client.post<Test>(
`/tests/${testId}/validate`,
payload,
);
return data;
}
/** Reject a test (legacy endpoint). */
export async function rejectTest(testId: string): Promise<Test> { export async function rejectTest(testId: string): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/reject`); const { data } = await client.post<Test>(`/tests/${testId}/reject`);
return data; return data;

View File

@@ -20,6 +20,8 @@ import { useState } from "react";
const testStateBadgeColors: Record<TestState, string> = { const testStateBadgeColors: Record<TestState, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30", draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30", in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30", validated: "bg-green-900/50 text-green-400 border-green-500/30",
rejected: "bg-red-900/50 text-red-400 border-red-500/30", rejected: "bg-red-900/50 text-red-400 border-red-500/30",
@@ -60,7 +62,7 @@ export default function TestDetailPage() {
}); });
const uploadMutation = useMutation({ const uploadMutation = useMutation({
mutationFn: (file: File) => uploadEvidence(testId!, file), mutationFn: (file: File) => uploadEvidence(testId!, file, "red"),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["test", testId] }); queryClient.invalidateQueries({ queryKey: ["test", testId] });
}, },
@@ -248,7 +250,7 @@ export default function TestDetailPage() {
{/* Evidence List */} {/* Evidence List */}
<div className="mt-6"> <div className="mt-6">
<EvidenceList <EvidenceList
evidences={test.evidences || []} evidences={[...(test.red_evidences || []), ...(test.blue_evidences || [])]}
onDownload={handleDownload} onDownload={handleDownload}
/> />
</div> </div>
@@ -277,11 +279,19 @@ export default function TestDetailPage() {
{formatDate(test.execution_date)} {formatDate(test.execution_date)}
</dd> </dd>
</div> </div>
{test.validated_at && ( {test.red_validated_at && (
<div> <div>
<dt className="text-xs font-medium uppercase text-gray-500">Validated</dt> <dt className="text-xs font-medium uppercase text-gray-500">Red Validated</dt>
<dd className="mt-1 text-sm text-gray-300"> <dd className="mt-1 text-sm text-gray-300">
{formatDate(test.validated_at)} {formatDate(test.red_validated_at)}
</dd>
</div>
)}
{test.blue_validated_at && (
<div>
<dt className="text-xs font-medium uppercase text-gray-500">Blue Validated</dt>
<dd className="mt-1 text-sm text-gray-300">
{formatDate(test.blue_validated_at)}
</dd> </dd>
</div> </div>
)} )}

View File

@@ -1,11 +1,15 @@
/* ── Shared TypeScript types matching the backend Pydantic schemas ── */ /* ── Shared TypeScript types matching the backend Pydantic schemas ── */
// ── Users ──────────────────────────────────────────────────────────
export interface User { export interface User {
id: string; id: string;
username: string; username: string;
role: string; role: string;
} }
// ── Techniques ─────────────────────────────────────────────────────
export interface Technique { export interface Technique {
id: string; id: string;
mitre_id: string; mitre_id: string;
@@ -30,6 +34,28 @@ export type TechniqueStatus =
| "not_covered" | "not_covered"
| "review_required"; | "review_required";
// ── Tests (v2 — Red/Blue dual validation) ──────────────────────────
export type TestState =
| "draft"
| "red_executing"
| "blue_evaluating"
| "in_review"
| "validated"
| "rejected";
export type TestResult = "detected" | "not_detected" | "partially_detected";
export type ValidationStatus = "pending" | "approved" | "rejected";
export type TeamSide = "red" | "blue";
/** States in which Red Team can edit / upload evidence. */
export const RED_EDITABLE_STATES: TestState[] = ["draft", "red_executing"];
/** States in which Blue Team can edit / upload evidence. */
export const BLUE_EDITABLE_STATES: TestState[] = ["blue_evaluating"];
export interface Test { export interface Test {
id: string; id: string;
technique_id: string; technique_id: string;
@@ -42,13 +68,30 @@ export interface Test {
created_by: string | null; created_by: string | null;
result: TestResult | null; result: TestResult | null;
state: TestState; state: TestState;
validated_by: string | null;
validated_at: string | null;
created_at: string; created_at: string;
// Red Team fields
red_summary: string | null;
attack_success: boolean | null;
red_validated_by: string | null;
red_validated_at: string | null;
red_validation_status: ValidationStatus | null;
red_validation_notes: string | null;
// Blue Team fields
blue_summary: string | null;
detection_result: TestResult | null;
blue_validated_by: string | null;
blue_validated_at: string | null;
blue_validation_status: ValidationStatus | null;
blue_validation_notes: string | null;
// Separated evidences
red_evidences: Evidence[];
blue_evidences: Evidence[];
} }
export type TestState = "draft" | "in_review" | "validated" | "rejected"; // ── Evidence (v2 — with team) ──────────────────────────────────────
export type TestResult = "detected" | "not_detected" | "partially_detected";
export interface Evidence { export interface Evidence {
id: string; id: string;
@@ -58,8 +101,51 @@ export interface Evidence {
sha256_hash: string; sha256_hash: string;
uploaded_by: string | null; uploaded_by: string | null;
uploaded_at: string; uploaded_at: string;
team: TeamSide;
notes: string | null;
download_url?: string;
} }
// ── Test Templates ─────────────────────────────────────────────────
export interface TestTemplate {
id: string;
mitre_technique_id: string;
name: string;
description: string | null;
source: string;
source_url: string | null;
attack_procedure: string | null;
expected_detection: string | null;
platform: string | null;
tool_suggested: string | null;
severity: string | null;
atomic_test_id: string | null;
is_active: boolean;
created_at: string;
}
export interface TestTemplateSummary {
id: string;
mitre_technique_id: string;
name: string;
source: string;
platform: string | null;
severity: string | null;
}
// ── Timeline ───────────────────────────────────────────────────────
export interface TestTimelineEntry {
id: string;
action: string;
user_id: string | null;
timestamp: string;
details: Record<string, unknown>;
}
// ── Intel ──────────────────────────────────────────────────────────
export interface IntelItem { export interface IntelItem {
id: string; id: string;
technique_id: string | null; technique_id: string | null;
@@ -70,6 +156,8 @@ export interface IntelItem {
reviewed: boolean; reviewed: boolean;
} }
// ── Metrics ────────────────────────────────────────────────────────
export interface CoverageSummary { export interface CoverageSummary {
total_techniques: number; total_techniques: number;
validated: number; validated: number;