MAESTRO: wire header analyzer controls and captcha

This commit is contained in:
Mariusz Banach
2026-02-18 04:40:57 +01:00
parent cdda2b987f
commit ffce9053a8
9 changed files with 199 additions and 22 deletions

View File

@@ -18,8 +18,10 @@ const { submitSpy, cancelSpy, useAnalysisState } = vi.hoisted(() => {
progress: null,
result: null,
error: null,
captchaChallenge: null,
submit: submitSpy,
cancel: cancelSpy,
clearCaptchaChallenge: vi.fn(),
},
};
});
@@ -86,6 +88,7 @@ const resetUseAnalysisState = (): void => {
useAnalysisState.progress = null;
useAnalysisState.result = null;
useAnalysisState.error = null;
useAnalysisState.captchaChallenge = null;
};
afterEach(() => {
@@ -152,4 +155,15 @@ describe("Home page", () => {
const results = container.querySelector('[data-testid="analysis-results"]');
expect(results).not.toBeNull();
});
it("renders the captcha modal when rate limited", () => {
useAnalysisState.captchaChallenge = {
challengeToken: "challenge-123",
imageBase64: "image-data",
};
const { container } = render(<Home />);
const modal = container.querySelector('[data-testid="captcha-challenge"]');
expect(modal).not.toBeNull();
});
});

View File

@@ -18,8 +18,10 @@ const { submitSpy, cancelSpy, useAnalysisState } = vi.hoisted(() => {
progress: null,
result: null,
error: null,
captchaChallenge: null,
submit: submitSpy,
cancel: cancelSpy,
clearCaptchaChallenge: vi.fn(),
},
};
});
@@ -118,6 +120,7 @@ const resetUseAnalysisState = (): void => {
useAnalysisState.progress = null;
useAnalysisState.result = null;
useAnalysisState.error = null;
useAnalysisState.captchaChallenge = null;
};
beforeEach(() => {

View File

@@ -4,7 +4,7 @@ import { act } from "react-dom/test-utils";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { apiClient } from "../lib/api-client";
import { ApiError, apiClient } from "../lib/api-client";
import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis";
import useAnalysis from "../hooks/useAnalysis";
@@ -105,7 +105,8 @@ const timeoutReport: AnalysisReport = {
};
const AnalysisHarness = ({ request, onStatusChange }: HarnessProps) => {
const { status, progress, result, error, submit, cancel } = useAnalysis();
const { status, progress, result, error, submit, cancel, captchaChallenge, clearCaptchaChallenge } =
useAnalysis();
useEffect(() => {
onStatusChange?.(status);
@@ -118,12 +119,16 @@ const AnalysisHarness = ({ request, onStatusChange }: HarnessProps) => {
<span data-testid="percentage">{progress?.percentage ?? ""}</span>
<span data-testid="result-total">{result?.metadata.totalTests ?? ""}</span>
<span data-testid="error">{error ?? ""}</span>
<span data-testid="captcha-token">{captchaChallenge?.challengeToken ?? ""}</span>
<button data-testid="submit" onClick={() => submit(request)}>
Submit
</button>
<button data-testid="cancel" onClick={() => cancel()}>
Cancel
</button>
<button data-testid="clear-captcha" onClick={() => clearCaptchaChallenge()}>
Clear Captcha
</button>
</div>
);
};
@@ -260,4 +265,24 @@ describe("useAnalysis", () => {
expect(abortSignal?.aborted).toBe(true);
expect(statuses).toContain("idle");
});
it("captures captcha challenges on rate limit errors", async () => {
vi.spyOn(apiClient, "stream").mockRejectedValue(
new ApiError("Too many requests", 429, {
captchaChallenge: { challengeToken: "abc123", imageBase64: "image-data" },
}),
);
const { container } = render(<AnalysisHarness request={baseRequest} />);
act(() => {
getByTestId(container, "submit").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
await flushPromises();
});
expect(getByTestId(container, "captcha-token").textContent).toBe("abc123");
});
});

View File

@@ -5,14 +5,18 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import AnalyseButton from "../components/AnalyseButton";
import AnalysisControls from "../components/AnalysisControls";
import CaptchaChallenge from "../components/CaptchaChallenge";
import FileDropZone from "../components/FileDropZone";
import HeaderInput from "../components/HeaderInput";
import ProgressIndicator from "../components/ProgressIndicator";
import ReportContainer from "../components/report/ReportContainer";
import useAnalysis from "../hooks/useAnalysis";
import useAnalysisCache from "../hooks/useAnalysisCache";
import { apiClient } from "../lib/api-client";
import { MAX_HEADER_INPUT_BYTES } from "../lib/header-validation";
import type { AnalysisConfig, AnalysisReport } from "../types/analysis";
import type { CaptchaVerifyPayload, CaptchaVerifyResponse } from "../types/captcha";
const defaultConfig: AnalysisConfig = {
testIds: [],
@@ -21,7 +25,8 @@ const defaultConfig: AnalysisConfig = {
};
export default function Home() {
const { status, progress, result, submit, cancel } = useAnalysis();
const { status, progress, result, submit, cancel, captchaChallenge, clearCaptchaChallenge } =
useAnalysis();
const { save, load, clear, isNearLimit } = useAnalysisCache();
const initialCache = useMemo(() => load(), [load]);
const [headerInput, setHeaderInput] = useState(() => initialCache?.headers ?? "");
@@ -36,6 +41,7 @@ export default function Home() {
);
const [isViewCleared, setIsViewCleared] = useState(false);
const lastSubmissionRef = useRef<{ headers: string; config: AnalysisConfig } | null>(null);
const bypassTokenRef = useRef<string | null>(null);
useEffect(() => {
if (!result) {
@@ -80,6 +86,10 @@ export default function Home() {
const payload = { headers: headerInput, config: analysisConfig };
lastSubmissionRef.current = payload;
setIsViewCleared(false);
if (bypassTokenRef.current) {
void submit(payload, { bypassToken: bypassTokenRef.current });
return;
}
void submit(payload);
}, [analysisConfig, canAnalyse, headerInput, submit]);
@@ -92,7 +102,44 @@ export default function Home() {
setCachedTimestamp(null);
setIsViewCleared(true);
lastSubmissionRef.current = null;
}, [cancel, clear]);
bypassTokenRef.current = null;
clearCaptchaChallenge();
}, [cancel, clear, clearCaptchaChallenge]);
const handleCaptchaVerify = useCallback(
async (payload: CaptchaVerifyPayload): Promise<string> => {
const response = await apiClient.post<CaptchaVerifyResponse, CaptchaVerifyPayload>(
"/api/captcha/verify",
payload,
);
if (!response.bypassToken) {
throw new Error("Captcha verification failed.");
}
return response.bypassToken;
},
[],
);
const handleCaptchaSuccess = useCallback((bypassToken: string) => {
bypassTokenRef.current = bypassToken;
}, []);
const handleCaptchaRetry = useCallback(() => {
const payload = lastSubmissionRef.current;
if (!payload) {
return;
}
setIsViewCleared(false);
if (bypassTokenRef.current) {
void submit(payload, { bypassToken: bypassTokenRef.current });
return;
}
void submit(payload);
}, [submit]);
const handleCaptchaClose = useCallback(() => {
clearCaptchaChallenge();
}, [clearCaptchaChallenge]);
return (
<main className="min-h-screen bg-background text-text">
@@ -112,7 +159,10 @@ export default function Home() {
</header>
<section className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<HeaderInput value={headerInput} onChange={setHeaderInput} />
<div className="flex flex-col gap-6">
<HeaderInput value={headerInput} onChange={setHeaderInput} />
<AnalysisControls config={analysisConfig} onChange={setAnalysisConfig} />
</div>
<div className="flex flex-col gap-6">
<FileDropZone onFileContent={setHeaderInput} />
@@ -198,6 +248,14 @@ export default function Home() {
) : null}
</div>
</div>
<CaptchaChallenge
isOpen={Boolean(captchaChallenge)}
challenge={captchaChallenge}
onVerify={handleCaptchaVerify}
onSuccess={handleCaptchaSuccess}
onRetry={handleCaptchaRetry}
onClose={handleCaptchaClose}
/>
</main>
);
}

View File

@@ -4,15 +4,7 @@ import { useCallback, useId, useLayoutEffect, useRef, useState, type KeyboardEve
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLock, faUnlock } from "@fortawesome/free-solid-svg-icons";
export type CaptchaChallengeData = {
challengeToken: string;
imageBase64: string;
};
export type CaptchaVerifyPayload = {
challengeToken: string;
answer: string;
};
import type { CaptchaChallengeData, CaptchaVerifyPayload } from "../types/captcha";
type CaptchaChallengeProps = {
isOpen: boolean;

View File

@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { apiClient, type SseEvent } from "../lib/api-client";
import { ApiError, apiClient, type SseEvent } from "../lib/api-client";
import type { CaptchaChallengeData } from "../types/captcha";
import type { AnalysisConfig, AnalysisProgress, AnalysisReport } from "../types/analysis";
export type AnalysisStatus = "idle" | "submitting" | "analysing" | "complete" | "error" | "timeout";
@@ -16,8 +17,14 @@ export interface UseAnalysisState {
progress: AnalysisProgress | null;
result: AnalysisReport | null;
error: string | null;
submit: (request: AnalysisRequest) => Promise<void>;
captchaChallenge: CaptchaChallengeData | null;
submit: (request: AnalysisRequest, options?: AnalysisSubmitOptions) => Promise<void>;
cancel: () => void;
clearCaptchaChallenge: () => void;
}
export interface AnalysisSubmitOptions {
bypassToken?: string;
}
const scheduleTask = (handler: () => void): void => {
@@ -29,6 +36,7 @@ const useAnalysis = (): UseAnalysisState => {
const [progress, setProgress] = useState<AnalysisProgress | null>(null);
const [result, setResult] = useState<AnalysisReport | null>(null);
const [error, setError] = useState<string | null>(null);
const [captchaChallenge, setCaptchaChallenge] = useState<CaptchaChallengeData | null>(null);
const abortRef = useRef<AbortController | null>(null);
const requestIdRef = useRef(0);
@@ -47,6 +55,11 @@ const useAnalysis = (): UseAnalysisState => {
setProgress(null);
setResult(null);
setError(null);
setCaptchaChallenge(null);
}, []);
const clearCaptchaChallenge = useCallback(() => {
setCaptchaChallenge(null);
}, []);
const handleEvent = useCallback(
@@ -92,7 +105,7 @@ const useAnalysis = (): UseAnalysisState => {
);
const submit = useCallback(
async (request: AnalysisRequest): Promise<void> => {
async (request: AnalysisRequest, options: AnalysisSubmitOptions = {}): Promise<void> => {
requestIdRef.current += 1;
const requestId = requestIdRef.current;
@@ -106,10 +119,15 @@ const useAnalysis = (): UseAnalysisState => {
hasProgressRef.current = false;
try {
const headers = options.bypassToken
? { "x-captcha-bypass-token": options.bypassToken }
: undefined;
await apiClient.stream<AnalysisRequest, AnalysisProgress | AnalysisReport>("/api/analyse", {
body: request,
signal: controller.signal,
onEvent: (event) => handleEvent(event, requestId, controller.signal),
headers,
});
} catch (err) {
if (!mountedRef.current || controller.signal.aborted) {
@@ -120,7 +138,13 @@ const useAnalysis = (): UseAnalysisState => {
}
inFlightRef.current = false;
const message = err instanceof Error ? err.message : "Unknown error";
let message = err instanceof Error ? err.message : "Unknown error";
if (err instanceof ApiError) {
message = err.message;
if (err.status === 429 && err.payload?.captchaChallenge) {
setCaptchaChallenge(err.payload.captchaChallenge);
}
}
setError(message);
setStatus("error");
}
@@ -143,8 +167,10 @@ const useAnalysis = (): UseAnalysisState => {
progress,
result,
error,
captchaChallenge,
submit,
cancel,
clearCaptchaChallenge,
};
};

View File

@@ -1,13 +1,12 @@
import type { CaptchaChallengeData } from "../types/captcha";
const DEFAULT_BASE_URL = "http://localhost:8000";
export interface ApiErrorPayload {
error?: string;
detail?: string;
retryAfter?: number;
captchaChallenge?: {
challengeToken: string;
imageBase64: string;
};
captchaChallenge?: CaptchaChallengeData;
}
export class ApiError extends Error {

View File

@@ -0,0 +1,14 @@
export interface CaptchaChallengeData {
challengeToken: string;
imageBase64: string;
}
export interface CaptchaVerifyPayload {
challengeToken: string;
answer: string;
}
export interface CaptchaVerifyResponse {
success: boolean;
bypassToken?: string | null;
}