diff --git a/frontend/src/components/Configuration/Configuration.test.tsx b/frontend/src/components/Configuration/Configuration.test.tsx index 6b3bff2754..e4f52abbb7 100644 --- a/frontend/src/components/Configuration/Configuration.test.tsx +++ b/frontend/src/components/Configuration/Configuration.test.tsx @@ -188,7 +188,7 @@ describe('Configuration', () => { 'C:/Users/test/.pyrit/custom_initializers/custom_target.py', { selector: 'label' }, )).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Add initializer' })) + fireEvent.click(screen.getByRole('button', { name: 'Add initializer' })) const dialog = screen.getByRole('dialog', { name: 'Add custom initializer' }) await user.type(within(dialog).getByRole('textbox', { name: /Initializer name/ }), 'new_custom') fireEvent.change(within(dialog).getByRole('textbox', { name: 'Python source' }), { diff --git a/frontend/src/components/Feedback/FeedbackDialog.test.tsx b/frontend/src/components/Feedback/FeedbackDialog.test.tsx index af623815fd..e434d917f2 100644 --- a/frontend/src/components/Feedback/FeedbackDialog.test.tsx +++ b/frontend/src/components/Feedback/FeedbackDialog.test.tsx @@ -47,25 +47,17 @@ describe("FeedbackDialog", () => { }); describe("shell", () => { - it("renders sensitive-info warning and links to the public repo", () => { + it("starts without a selected feedback type or form fields", () => { renderDialog(); expect(screen.getByText("Send feedback")).toBeInTheDocument(); - const warning = screen.getByTestId("feedback-sensitive-warning"); - expect(warning).toHaveTextContent(/public/i); - expect(warning).toHaveTextContent(/secrets/i); - expect(warning).toHaveTextContent(/credentials/i); - expect(warning).toHaveTextContent(/customer data/i); - expect(warning).toHaveTextContent(/proprietary/i); + expect(screen.getByRole("combobox", { name: /category/i })).toHaveValue(""); expect( - screen.getByRole("link", { name: /github\.com\/microsoft\/PyRIT/i }), - ).toHaveAttribute("href", "https://github.com/microsoft/PyRIT/issues"); + screen.queryByTestId("feedback-bug-describe-input"), + ).not.toBeInTheDocument(); expect( - screen.getByRole("link", { name: /microsoft privacy statement/i }), - ).toHaveAttribute( - "href", - "https://privacy.microsoft.com/en-us/privacystatement", - ); + screen.queryByTestId("feedback-submit-button"), + ).not.toBeInTheDocument(); }); it("does not render when closed", () => { @@ -73,13 +65,21 @@ describe("FeedbackDialog", () => { expect(screen.queryByText("Send feedback")).not.toBeInTheDocument(); }); - it("offers all five template-backed categories in the dropdown", () => { + it("offers security and all five template-backed categories in the dropdown", () => { renderDialog(); const select = screen.getByTestId( "feedback-category-select", ) as HTMLSelectElement; const values = Array.from(select.options).map((o) => o.value); - expect(values).toEqual(["bug", "feature", "doc", "praise", "other"]); + expect(values).toEqual([ + "", + "security", + "bug", + "feature", + "doc", + "praise", + "other", + ]); }); it("calls onClose when Cancel is clicked without opening any tab", async () => { @@ -93,8 +93,9 @@ describe("FeedbackDialog", () => { }); describe("category-driven fields", () => { - it("defaults to the bug category and renders bug-specific fields", () => { + it("renders bug-specific fields and the public issue warning for bug reports", async () => { renderDialog(); + await pickCategory("bug"); expect( screen.getByTestId("feedback-bug-describe-input"), ).toBeInTheDocument(); @@ -104,6 +105,42 @@ describe("FeedbackDialog", () => { expect( screen.getByTestId("feedback-bug-versions-input"), ).toBeInTheDocument(); + expect(screen.getByTestId("feedback-sensitive-warning")).toHaveTextContent( + /public GitHub issue/i, + ); + expect( + screen.getByRole("link", { name: /microsoft privacy statement/i }), + ).toHaveAttribute( + "href", + "https://privacy.microsoft.com/en-us/privacystatement", + ); + }); + + it("shows only the private reporting route for security vulnerabilities", async () => { + renderDialog(); + await pickCategory("security"); + + expect(screen.getByTestId("feedback-security-guidance")).toHaveTextContent( + /must be reported privately/i, + ); + expect( + screen.getByRole("link", { name: /PyRIT security reporting process/i }), + ).toHaveAttribute( + "href", + "https://github.com/microsoft/PyRIT/security/policy", + ); + expect( + screen.getByRole("link", { name: /open private reporting process/i }), + ).toHaveAttribute( + "href", + "https://github.com/microsoft/PyRIT/security/policy", + ); + expect( + screen.queryByTestId("feedback-submit-button"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("feedback-contact-input"), + ).not.toBeInTheDocument(); }); it("swaps to feature-request fields when feature is selected", async () => { @@ -152,7 +189,7 @@ describe("FeedbackDialog", () => { it("clears prior fields when switching categories", async () => { renderDialog(); - const user = userEvent.setup(); + const user = await pickCategory("bug"); const describe = screen.getByTestId( "feedback-bug-describe-input", ) as HTMLTextAreaElement; @@ -180,6 +217,7 @@ describe("FeedbackDialog", () => { describe("submit gate", () => { it("disables Continue on GitHub until the primary field reaches the minimum length", async () => { renderDialog(); + await pickCategory("bug"); const submit = screen.getByTestId("feedback-submit-button"); expect(submit).toBeDisabled(); @@ -206,7 +244,7 @@ describe("FeedbackDialog", () => { it("submits a bug to the bug_report template with GUI + Bug: triage labels", async () => { const onClose = jest.fn(); renderDialog({ onClose }); - const user = userEvent.setup(); + const user = await pickCategory("bug"); const describe = screen.getByTestId("feedback-bug-describe-input"); await user.click(describe); await user.paste("Chat window crashes on empty send"); @@ -297,7 +335,7 @@ describe("FeedbackDialog", () => { it("omits the contact section when the contact field is blank", async () => { renderDialog(); - const user = userEvent.setup(); + const user = await pickCategory("bug"); const describe = screen.getByTestId("feedback-bug-describe-input"); await user.click(describe); await user.paste("Some sufficiently long bug description here"); @@ -311,7 +349,7 @@ describe("FeedbackDialog", () => { it("includes the contact section when one is provided", async () => { renderDialog(); - const user = userEvent.setup(); + const user = await pickCategory("bug"); const describe = screen.getByTestId("feedback-bug-describe-input"); await user.click(describe); await user.paste("Some sufficiently long bug description here"); @@ -345,7 +383,7 @@ describe("FeedbackDialog", () => { describe("secret detection", () => { it("does not show the secret warning for plain prose", async () => { renderDialog(); - const user = userEvent.setup(); + const user = await pickCategory("bug"); const describe = screen.getByTestId("feedback-bug-describe-input"); await user.click(describe); await user.paste("Plain feedback with no secrets at all."); @@ -356,7 +394,7 @@ describe("FeedbackDialog", () => { it("shows the secret warning when a token-like value appears in any field", async () => { renderDialog(); - const user = userEvent.setup(); + const user = await pickCategory("bug"); const repro = screen.getByTestId("feedback-bug-repro-input"); await user.click(repro); await user.paste( @@ -369,7 +407,7 @@ describe("FeedbackDialog", () => { it("opens the confirm dialog instead of GitHub when submitting with a detected secret", async () => { renderDialog(); - const user = userEvent.setup(); + const user = await pickCategory("bug"); const describe = screen.getByTestId("feedback-bug-describe-input"); await user.click(describe); await user.paste( @@ -386,7 +424,7 @@ describe("FeedbackDialog", () => { it("cancels submission when the user clicks 'Go back and fix'", async () => { const onClose = jest.fn(); renderDialog({ onClose }); - const user = userEvent.setup(); + const user = await pickCategory("bug"); const describe = screen.getByTestId("feedback-bug-describe-input"); await user.click(describe); await user.paste( @@ -402,7 +440,7 @@ describe("FeedbackDialog", () => { it("proceeds to GitHub when the user explicitly clicks 'Submit anyway'", async () => { const onClose = jest.fn(); renderDialog({ onClose }); - const user = userEvent.setup(); + const user = await pickCategory("bug"); const describe = screen.getByTestId("feedback-bug-describe-input"); await user.click(describe); await user.paste( @@ -418,7 +456,7 @@ describe("FeedbackDialog", () => { it("resets the form when the dialog is reopened", async () => { const { unmount } = renderDialog(); - const user = userEvent.setup(); + const user = await pickCategory("bug"); const describe = screen.getByTestId( "feedback-bug-describe-input", ) as HTMLTextAreaElement; @@ -429,9 +467,9 @@ describe("FeedbackDialog", () => { unmount(); renderDialog(); - const describeAfter = screen.getByTestId( - "feedback-bug-describe-input", - ) as HTMLTextAreaElement; - expect(describeAfter.value).toBe(""); + expect(screen.getByRole("combobox", { name: /category/i })).toHaveValue(""); + expect( + screen.queryByTestId("feedback-bug-describe-input"), + ).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/components/Feedback/FeedbackDialog.tsx b/frontend/src/components/Feedback/FeedbackDialog.tsx index b4c64377fe..20221595f6 100644 --- a/frontend/src/components/Feedback/FeedbackDialog.tsx +++ b/frontend/src/components/Feedback/FeedbackDialog.tsx @@ -40,8 +40,14 @@ interface FeedbackDialogProps { context?: FeedbackContext } +type FeedbackSelection = FeedbackCategory | 'security' + // The order here is also the order in the dropdown. -const CATEGORIES: { value: FeedbackCategory; helper: string }[] = [ +const CATEGORIES: { value: FeedbackSelection; helper: string }[] = [ + { + value: 'security', + helper: 'Privately report a potential security vulnerability', + }, { value: 'bug', helper: 'Something is broken or producing the wrong result' }, { value: 'feature', helper: 'An idea or improvement you would like to see' }, { value: 'doc', helper: 'Documentation is missing, confusing, or out of date' }, @@ -49,6 +55,8 @@ const CATEGORIES: { value: FeedbackCategory; helper: string }[] = [ { value: 'other', helper: 'Anything else' }, ] +const SECURITY_POLICY_URL = 'https://github.com/microsoft/PyRIT/security/policy' + // Keep the assembled body short enough that the URL-encoded GitHub issue URL // fits well within browser and intermediate-proxy limits (~8 KB URL is safe). const MAX_FIELD_LENGTH = 5_000 @@ -95,6 +103,10 @@ const useStyles = makeStyles({ }, }) +function isFeedbackCategory(category: FeedbackSelection | ''): category is FeedbackCategory { + return category !== '' && category !== 'security' +} + /** Returns true iff the user has filled in enough to build a useful issue. */ function getPrimaryField( category: FeedbackCategory, @@ -160,7 +172,7 @@ function buildInput( export default function FeedbackDialog({ open, onClose, context }: FeedbackDialogProps) { const styles = useStyles() - const [category, setCategory] = useState('bug') + const [category, setCategory] = useState('') const [fields, setFields] = useState({}) const [optionalContact, setOptionalContact] = useState('') const [confirmOpen, setConfirmOpen] = useState(false) @@ -168,7 +180,9 @@ export default function FeedbackDialog({ open, onClose, context }: FeedbackDialo const update = (name: keyof DialogFields, value: string) => setFields((prev) => ({ ...prev, [name]: value })) - const primary = getPrimaryField(category, fields) + const primary = isFeedbackCategory(category) + ? getPrimaryField(category, fields) + : { name: 'body' as const, value: '' } const primaryTrimmed = primary.value.trim() const primaryTooShort = primaryTrimmed.length > 0 && primaryTrimmed.length < MIN_PRIMARY_LENGTH @@ -217,6 +231,7 @@ export default function FeedbackDialog({ open, onClose, context }: FeedbackDialo } const fireSubmit = () => { + if (!isFeedbackCategory(category)) return const input = buildInput(category, fields, optionalContact || undefined, context) const url = buildGithubFeedbackUrl(input) window.open(url, '_blank', 'noopener,noreferrer') @@ -246,92 +261,126 @@ export default function FeedbackDialog({ open, onClose, context }: FeedbackDialo handleSubmit() }} > - - GitHub issues are public. Please do not include secrets, credentials, - customer data, model endpoints, or other proprietary information. Your - feedback will be filed at{' '} - - github.com/microsoft/PyRIT - - . - - {helperForCategory} - + {category === 'security' && ( + + Security vulnerabilities must be reported privately. Do not enter + vulnerability details in this form. Use the{' '} + + PyRIT security reporting process + + . + + )} - - setOptionalContact(data.value)} - placeholder="GitHub handle, email, alias — if you would like a reply" - data-testid="feedback-contact-input" - /> - + {isFeedbackCategory(category) && ( + <> + + This feedback is filed as a public GitHub issue. Do not include + confidential information. + - + - - Continuing opens a new tab on github.com with this form pre-filled. You - will need a GitHub account to file the issue. Data you submit is - governed by the{' '} - - Microsoft Privacy Statement - - . - + + setOptionalContact(data.value)} + placeholder="GitHub handle, email, alias — if you would like a reply" + data-testid="feedback-contact-input" + /> + + + + + + Continuing opens a new tab on github.com with this form pre-filled. + You will need a GitHub account to file the issue. Data you submit is + governed by the{' '} + + Microsoft Privacy Statement + + . + + + )} - + {category === 'security' && ( + + )} + {isFeedbackCategory(category) && ( + + )} diff --git a/frontend/src/components/Sidebar/Navigation.test.tsx b/frontend/src/components/Sidebar/Navigation.test.tsx index 9a10672321..bac2181aec 100644 --- a/frontend/src/components/Sidebar/Navigation.test.tsx +++ b/frontend/src/components/Sidebar/Navigation.test.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT license. */ -import { fireEvent, render, screen } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ThemeProvider, useTheme } from "../../hooks/useTheme"; import Navigation from "./Navigation"; @@ -119,25 +119,25 @@ describe("Navigation", () => { ).toBeInTheDocument(); }); - it("renders the feedback button and forwards clicks to onOpenFeedback", () => { + it("renders one feedback button and forwards clicks to onOpenFeedback", async () => { + const user = userEvent.setup(); const onOpenFeedback = jest.fn(); renderWithProvider( ); - const feedbackButton = screen.getByTitle("Feedback"); - expect(feedbackButton).toBeInTheDocument(); - fireEvent.click(feedbackButton); + const feedbackButtons = screen.getAllByRole("button", { name: "Feedback" }); + expect(feedbackButtons).toHaveLength(1); + await user.click(feedbackButtons[0]); expect(onOpenFeedback).toHaveBeenCalledTimes(1); }); - it("links to the public security policy", () => { + it("does not render a direct security link", () => { renderWithProvider(); - expect(screen.getByRole("link", { name: "Security" })).toHaveAttribute( - "href", - "https://github.com/microsoft/PyRIT/security/policy" - ); + expect( + screen.queryByRole("link", { name: "Security" }) + ).not.toBeInTheDocument(); }); it("calls onNavigate with 'history' when history button is clicked", async () => { diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx index 8a19b2fe35..1a6823d736 100644 --- a/frontend/src/components/Sidebar/Navigation.tsx +++ b/frontend/src/components/Sidebar/Navigation.tsx @@ -15,7 +15,6 @@ import { HistoryRegular, PersonFeedbackRegular, WrenchRegular, - OpenRegular, WeatherMoonRegular, WeatherSunnyRegular, TargetRegular, @@ -149,17 +148,6 @@ export default function Navigation({ aria-label="Feedback" onClick={onOpenFeedback} /> -