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 type { Evidence, TeamSide } from "../types/models";
export interface EvidenceOut {
id: string;
test_id: string;
file_name: string;
sha256_hash: string;
uploaded_by: string | null;
uploaded_at: string;
// ── Response type (with download URL) ──────────────────────────────
export interface EvidenceOut extends Evidence {
download_url: string;
}
/** Upload evidence file for a test. */
export async function uploadEvidence(testId: string, file: File): Promise<EvidenceOut> {
// ── Upload ─────────────────────────────────────────────────────────
/** 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();
formData.append("file", file);
formData.append("team", team);
if (notes) {
formData.append("notes", notes);
}
const { data } = await client.post<EvidenceOut>(`/tests/${testId}/evidence`, formData, {
headers: {
"Content-Type": "multipart/form-data",
const { data } = await client.post<EvidenceOut>(
`/tests/${testId}/evidence`,
formData,
{
headers: {
"Content-Type": "multipart/form-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> {
const { data } = await client.get<EvidenceOut>(`/evidence/${evidenceId}`);
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 type { Technique, TechniqueStatus } from "../types/models";
import type { Technique, TechniqueStatus, Test, IntelItem } from "../types/models";
/** Summary representation used in list endpoints. */
export interface TechniqueSummary {
@@ -13,31 +13,8 @@ export interface TechniqueSummary {
/** Extended technique with tests for detail view. */
export interface TechniqueWithTests extends Technique {
tests?: Array<{
id: string;
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;
}>;
tests?: Test[];
intel_items?: IntelItem[];
}
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 type { Test, TestResult } from "../types/models";
import type {
Test,
TestResult,
TestState,
TestTimelineEntry,
} from "../types/models";
// ── Payloads ───────────────────────────────────────────────────────
export interface TestCreatePayload {
technique_id: string;
@@ -19,21 +26,57 @@ export interface TestUpdatePayload {
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 {
result: TestResult;
comments?: string;
}
export interface TestWithEvidences extends Test {
evidences?: Array<{
id: string;
test_id: string;
file_name: string;
sha256_hash: string;
uploaded_by: string | null;
uploaded_at: string;
download_url?: string;
}>;
export interface TestListFilters {
state?: TestState;
technique_id?: string;
offset?: number;
limit?: number;
}
// ── CRUD ───────────────────────────────────────────────────────────
/** 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. */
@@ -42,25 +85,134 @@ export async function createTest(payload: TestCreatePayload): Promise<Test> {
return data;
}
/** Get test by ID with evidences. */
export async function getTestById(testId: string): Promise<TestWithEvidences> {
const { data } = await client.get<TestWithEvidences>(`/tests/${testId}`);
/** Create a test from an existing template. */
export async function createTestFromTemplate(
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;
}
/** 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);
return data;
}
/** Validate a test. */
export async function validateTest(testId: string, payload: TestValidatePayload): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/validate`, payload);
// ── Red Team ───────────────────────────────────────────────────────
/** 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;
}
/** 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> {
const { data } = await client.post<Test>(`/tests/${testId}/reject`);
return data;