From 7e57143152bdf2647306518e736b268a5cc5cb2f Mon Sep 17 00:00:00 2001 From: Omur Date: Wed, 22 Apr 2026 12:02:02 +0300 Subject: [PATCH 1/5] validation --- web-report/src-e2e/App.test.tsx | 81 +++++++- web-report/src/AppProvider.tsx | 161 +++++++++++++++- web-report/src/components/Dashboard.tsx | 14 +- .../src/components/TestDetailDialog.tsx | 56 ++++++ web-report/src/components/TestReviewRow.tsx | 73 +++++++ web-report/src/pages/TestResults.tsx | 5 +- web-report/src/pages/Tests.tsx | 181 ++++++++++++++++++ web-report/src/types/Review.ts | 82 ++++++++ 8 files changed, 645 insertions(+), 8 deletions(-) create mode 100644 web-report/src/components/TestDetailDialog.tsx create mode 100644 web-report/src/components/TestReviewRow.tsx create mode 100644 web-report/src/pages/Tests.tsx create mode 100644 web-report/src/types/Review.ts diff --git a/web-report/src-e2e/App.test.tsx b/web-report/src-e2e/App.test.tsx index e55b676..f0bf882 100644 --- a/web-report/src-e2e/App.test.tsx +++ b/web-report/src-e2e/App.test.tsx @@ -1,4 +1,4 @@ -import {render, waitFor, screen, act, fireEvent} from '@testing-library/react'; +import {render, waitFor, screen, act, fireEvent, within} from '@testing-library/react'; import '@testing-library/jest-dom'; import {resolve} from "path"; import {readFileSync} from "fs"; @@ -18,7 +18,7 @@ vi.mock('@/lib/utils.tsx', async (importOriginal) => { fetchFileContent: vi.fn(async (filePath: string) => { const folderPath = resolve(__dirname, './static/'); const fullPath = resolve(folderPath, filePath); - if (fullPath.endsWith('.json')) { + if (fullPath.endsWith('report.json')) { return reportData; } else if (filePath.endsWith('.java')) { return readFileSync(fullPath, 'utf-8'); @@ -134,6 +134,83 @@ describe('App test', () => { }); }); + it('changing a review state marks it dirty and updates counts', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('tab-tests')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.focus(screen.getByTestId('tab-tests')); + }); + + await waitFor(() => { + expect(screen.getByTestId('test-file-0')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(within(screen.getByTestId('test-file-0')).getByRole('button')); + }); + + const firstTestId = reportData.testCases[0].id as string; + await waitFor(() => { + expect(screen.getByTestId(`test-review-state-${firstTestId}`)).toBeInTheDocument(); + }); + + act(() => { + fireEvent.change(screen.getByTestId(`test-review-state-${firstTestId}`), { + target: {value: 'ACCEPTED'}, + }); + }); + + await waitFor(() => { + expect(screen.getByTestId('reviews-unsaved-banner')).toBeInTheDocument(); + expect(screen.getByTestId('reviews-filter-ACCEPTED')).toHaveTextContent('ACCEPTED (1)'); + expect(screen.getByTestId('reviews-filter-NOT-REVIEWED')) + .toHaveTextContent(`NOT-REVIEWED (${reportData.testCases.length - 1})`); + }); + }); + + it('clicking a test opens the detail dialog and close dismisses it', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('tab-tests')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.focus(screen.getByTestId('tab-tests')); + }); + + await waitFor(() => { + expect(screen.getByTestId('test-file-0')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(within(screen.getByTestId('test-file-0')).getByRole('button')); + }); + + const firstTestId = reportData.testCases[0].id as string; + await waitFor(() => { + expect(screen.getByTestId(`test-review-open-${firstTestId}`)).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(screen.getByTestId(`test-review-open-${firstTestId}`)); + }); + + await waitFor(() => { + expect(screen.getByTestId('test-detail-dialog')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(screen.getByTestId('test-detail-dialog-close')); + }); + + await waitFor(() => { + expect(screen.queryByTestId('test-detail-dialog')).toBeNull(); + }); + }); + it('check faults component', async () => { render(); expect(screen.getByText(/Please wait, files are loading.../)).toBeInTheDocument(); diff --git a/web-report/src/AppProvider.tsx b/web-report/src/AppProvider.tsx index a84f0f6..8689b9c 100644 --- a/web-report/src/AppProvider.tsx +++ b/web-report/src/AppProvider.tsx @@ -1,9 +1,19 @@ -import {createContext, useContext, useState, ReactNode, useEffect} from 'react'; +import {createContext, useContext, useState, ReactNode, useEffect, useRef, useCallback} from 'react'; import {WebFuzzingCommonsReport} from "@/types/GeneratedTypes.tsx"; import {ITestFiles} from "@/types/General.tsx"; import {fetchFileContent, ITransformedReport, transformWebFuzzingReport} from "@/lib/utils.tsx"; import {webFuzzingCommonsReportSchema} from "@/types/GeneratedTypesZod.ts"; import {ZodIssue} from "zod"; +import { + DEFAULT_REVIEW, + parseReviewFile, + REVIEW_FILE_NAME, + REVIEW_SCHEMA_VERSION, + ReviewFile, + ReviewState, + reviewsEqual, + TestReview, +} from "@/types/Review.ts"; type AppContextType = { data: WebFuzzingCommonsReport | null; @@ -16,6 +26,15 @@ type AppContextType = { invalidReportErrors: ZodIssue[] | null; lowCodeMode: boolean; setLowCodeMode: (v: boolean) => void; + reviews: Record; + getReview: (testId: string) => TestReview; + setReviewState: (testId: string, state: ReviewState) => void; + setReviewComment: (testId: string, comment: string) => void; + isDirty: boolean; + saveReviews: () => void; + loadReviews: (file: File) => Promise; + reviewMessage: {type: "info" | "error"; text: string} | null; + clearReviewMessage: () => void; }; const AppContext = createContext(undefined); @@ -36,6 +55,15 @@ export const AppProvider = ({ children }: AppProviderProps) => { const [lowCodeMode, setLowCodeMode] = useState(initialLowCode); const transformedReport = transformWebFuzzingReport(data); + const [reviews, setReviews] = useState>({}); + const baselineRef = useRef>({}); + const [isDirty, setIsDirty] = useState(false); + const [reviewMessage, setReviewMessage] = useState<{type: "info" | "error"; text: string} | null>(null); + + useEffect(() => { + setIsDirty(!reviewsEqual(reviews, baselineRef.current)); + }, [reviews]); + useEffect(() => { const fetchData = async () => { try { @@ -106,6 +134,113 @@ export const AppProvider = ({ children }: AppProviderProps) => { } }, [data]); + useEffect(() => { + if (!data) return; + if (window.location.protocol === 'file:') { + setReviewMessage({ + type: "info", + text: `Auto-loading ${REVIEW_FILE_NAME} is not possible when opening this page directly from disk. Either launch the report via webreport.py, or click "Load reviews" to import the file manually.`, + }); + return; + } + let cancelled = false; + fetchFileContent('./' + REVIEW_FILE_NAME) + .then(parsed => { + if (cancelled || !parsed) return; + try { + const loaded = parseReviewFile(parsed); + setReviews(loaded); + baselineRef.current = loaded; + setIsDirty(false); + setReviewMessage({ + type: "info", + text: `Auto-loaded ${Object.keys(loaded).length} review(s) from ${REVIEW_FILE_NAME}.`, + }); + } catch (e) { + console.warn("Auto-load of reviews failed:", e); + } + }) + .catch(() => { /* silent — expected when no review file is present or under file:// */ }); + return () => { cancelled = true; }; + }, [data]); + + useEffect(() => { + if (!isDirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [isDirty]); + + const getReview = useCallback( + (testId: string): TestReview => reviews[testId] ?? DEFAULT_REVIEW, + [reviews], + ); + + const setReviewState = useCallback((testId: string, state: ReviewState) => { + setReviews(prev => { + const existing = prev[testId] ?? DEFAULT_REVIEW; + return {...prev, [testId]: {...existing, state}}; + }); + }, []); + + const setReviewComment = useCallback((testId: string, comment: string) => { + setReviews(prev => { + const existing = prev[testId] ?? DEFAULT_REVIEW; + return {...prev, [testId]: {...existing, comment}}; + }); + }, []); + + const saveReviews = useCallback(() => { + const file: ReviewFile = { + schemaVersion: REVIEW_SCHEMA_VERSION, + reviews: {}, + }; + for (const [id, r] of Object.entries(reviews)) { + const comment = (r.comment ?? "").trim(); + if (r.state !== "NOT-REVIEWED" || comment !== "") { + file.reviews[id] = {state: r.state, comment: r.comment ?? ""}; + } + } + const blob = new Blob([JSON.stringify(file, null, 2)], {type: "application/json"}); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = REVIEW_FILE_NAME; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + baselineRef.current = {...reviews}; + setIsDirty(false); + setReviewMessage({ + type: "info", + text: `Saved ${Object.keys(file.reviews).length} review(s). Place the downloaded ${REVIEW_FILE_NAME} next to report.json.`, + }); + }, [reviews]); + + const loadReviews = useCallback(async (file: File) => { + try { + const text = await file.text(); + const parsed = JSON.parse(text); + const loaded = parseReviewFile(parsed); + setReviews(loaded); + baselineRef.current = loaded; + setIsDirty(false); + setReviewMessage({ + type: "info", + text: `Loaded ${Object.keys(loaded).length} review(s) from ${file.name}.`, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setReviewMessage({type: "error", text: `Failed to load reviews: ${msg}`}); + } + }, []); + + const clearReviewMessage = useCallback(() => setReviewMessage(null), []); + const [filteredEndpoints, setFilteredEndpoints] = useState(transformedReport); useEffect(() => { @@ -175,7 +310,27 @@ export const AppProvider = ({ children }: AppProviderProps) => { return filtered; } - const value: AppContextType = { data, loading, error, testFiles, transformedReport, filterEndpoints, filteredEndpoints, invalidReportErrors, lowCodeMode, setLowCodeMode }; + const value: AppContextType = { + data, + loading, + error, + testFiles, + transformedReport, + filterEndpoints, + filteredEndpoints, + invalidReportErrors, + lowCodeMode, + setLowCodeMode, + reviews, + getReview, + setReviewState, + setReviewComment, + isDirty, + saveReviews, + loadReviews, + reviewMessage, + clearReviewMessage, + }; return ( @@ -190,4 +345,4 @@ export const useAppContext = (): AppContextType => { throw new Error('useAppContext must be used within AppProvider'); } return context; -}; \ No newline at end of file +}; diff --git a/web-report/src/components/Dashboard.tsx b/web-report/src/components/Dashboard.tsx index f5b1f2e..856565d 100644 --- a/web-report/src/components/Dashboard.tsx +++ b/web-report/src/components/Dashboard.tsx @@ -6,6 +6,7 @@ import {Header} from "@/components/Header.tsx"; import {Overview} from "@/pages/Overview.tsx"; import {Endpoints} from "@/pages/Endpoints.tsx"; import {TestResults} from "@/pages/TestResults.tsx"; +import {Tests} from "@/pages/Tests.tsx"; import {ScrollArea, ScrollBar} from "@/components/ui/scroll-area.tsx"; import {useAppContext} from "@/AppProvider.tsx"; @@ -16,7 +17,7 @@ export interface ITestTabs { } export const Dashboard: React.FC = () => { - const {data} = useAppContext(); + const {data, isDirty} = useAppContext(); const [activeTab, setActiveTab] = useState("overview") @@ -79,6 +80,13 @@ export const Dashboard: React.FC = () => { > Endpoints + + Tests{isDirty && } +
@@ -123,6 +131,10 @@ export const Dashboard: React.FC = () => { + + + + { testTabs.map((test, index) => ( diff --git a/web-report/src/components/TestDetailDialog.tsx b/web-report/src/components/TestDetailDialog.tsx new file mode 100644 index 0000000..8479ade --- /dev/null +++ b/web-report/src/components/TestDetailDialog.tsx @@ -0,0 +1,56 @@ +import React, {useEffect} from "react"; +import {X} from "lucide-react"; +import {TestResults} from "@/pages/TestResults.tsx"; + +interface IProps { + testId: string | null; + onClose: () => void; +} + +export const TestDetailDialog: React.FC = ({testId, onClose}) => { + useEffect(() => { + if (!testId) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + window.removeEventListener("keydown", handler); + document.body.style.overflow = prevOverflow; + }; + }, [testId, onClose]); + + if (!testId) return null; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + > +
+ {testId} + +
+
+ +
+
+
+ ); +}; diff --git a/web-report/src/components/TestReviewRow.tsx b/web-report/src/components/TestReviewRow.tsx new file mode 100644 index 0000000..8fab922 --- /dev/null +++ b/web-report/src/components/TestReviewRow.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import {Code, ChevronRight} from "lucide-react"; +import {useAppContext} from "@/AppProvider.tsx"; +import {ReviewState, SELECTABLE_REVIEW_STATES} from "@/types/Review.ts"; + +interface IProps { + testId: string; + testName: string; + onOpen: (testId: string) => void; +} + +const stateBadgeClass = (state: ReviewState): string => { + switch (state) { + case "ACCEPTED": + return "bg-green-100 border-green-500 text-green-800"; + case "REJECTED": + return "bg-red-100 border-red-500 text-red-800"; + case "PREVIOUSLY-ACCEPTED": + return "bg-green-50 border-green-300 text-green-700"; + case "PREVIOUSLY-REJECTED": + return "bg-red-50 border-red-300 text-red-700"; + case "NOT-REVIEWED": + default: + return "bg-gray-100 border-gray-400 text-gray-700"; + } +}; + +export const TestReviewRow: React.FC = ({testId, testName, onOpen}) => { + const {getReview, setReviewState, setReviewComment} = useAppContext(); + const review = getReview(testId); + + return ( +
+ + + + +