From 96492b0ebc47545d5560e520be2117b5bb8a8942 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:10:38 +0000 Subject: [PATCH 1/6] fix(privacy): widen the patient-name check and stop refusing helplines and guideline dates Privacy audit PD-01 and PD-02 (2026-10-26). The Clinical question check now reads given names beyond the English list, a listed surname on either side (surname-first names too), a surname then a given name, 'Jane T.', 'Jane (Surname)', a possessive given name, Prof and Sr titles, and a lower-case title with one letter. 1300 and 1800 service numbers and dates a guideline, policy or protocol carries are set aside before the phone and date checks. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EyULT41weiAuu3CroGX7oD --- src/lib/clinical-question-bare-name.ts | 136 ++++++++++++++++-- src/lib/clinical-question-patient-check.ts | 31 +++- tests/clinical-question-patient-check.test.ts | 89 +++++++++++- 3 files changed, 237 insertions(+), 19 deletions(-) diff --git a/src/lib/clinical-question-bare-name.ts b/src/lib/clinical-question-bare-name.ts index 84dc01bcf1..deceea3015 100644 --- a/src/lib/clinical-question-bare-name.ts +++ b/src/lib/clinical-question-bare-name.ts @@ -44,11 +44,55 @@ philippa phoebe priya rachel rebecca rhys richard robert roberta rodney rosemary samantha samuel sarah scott sean shane shannon sharon shaun sheila simon simone sophia sophie stephanie stephen steven stuart susan tamara tanya teresa theresa thomas timothy toby todd tracey tracy trevor troy valerie vanessa vincent warren wayne wendy william xavier yvonne zachary zoe +mary jack ben sam kate alex chris tom tim dan jake josh beth jess liz tess rosie millie levi ezra theo leo +jayden jaxon tahlia talia shania kayla mikayla jordan tyson brodie cody riley harley kobe zac zach hayden +mick steve dave mike pete ken jon +minh thanh duc huy linh hai trang tuan khanh phuong quang thuy hoa +bao lin wei ming jing yan hui xin jun lei hao xiao ying lili mei ling jian zhen qing +arjun rahul rohan raj ravi vikram sanjay ajay amit anil sunil deepak suresh ramesh rajesh mahesh ganesh +kiran priyanka anjali deepika kavya neha pooja sneha divya lakshmi meera nisha sunita anita rekha shreya ananya +aditi aditya arun vijay harpreet gurpreet manpreet jaspreet simran navneet aarav ishaan vivek nikhil +omar ali mohammad muhammed mohamad hassan hussein ibrahim yusuf youssef khalid karim tariq bilal hamza mustafa mahmoud ahmad amir ayesha +aisha fatima fatma mariam maryam zainab layla leila noor nour yasmin samira amina hiba rania salma khadija +kwame kofi abena chidi chinedu chukwuemeka emeka ngozi adaeze amara tunde femi yemi amadou mamadou fatou +aminata abdi fatuma hodan deng achol garang mohamed mohamud +aroha rangi tane wiremu hemi manaia nikau sione mele tevita malia +siobhan niamh aoife ciaran declan jarrah kirra bindi marlee jedda kalinda kaya tanika +giulia giuseppe giovanni marco luca matteo alessandro francesca chiara lorenzo sofia joao pedro carlos +juan jose luis miguel javier diego lucia ines nikolai ivan sergei olga natalia katarina anastasia marek +dmitri piotr tomasz agnieszka katarzyna stefan andreas klaus ingrid astrid lars sven erik nils elif mehmet emre +hiroshi yuki haruto sakura kenji takeshi akira hana jin hyun ` .split(/\s+/) .filter(Boolean), ); +/** + * Surnames common in Australia that anchor a name from the other side: a surname-first name + * ("Nguyen Thi Lan", "Chen Wei"), or a given name the list above does not hold ("Wei Chen" is + * caught either way, "Xiaoling Chen" only by this). Surnames that are also eponyms, everyday words + * or a test name ("Wilson", "Williams", "Turner", "Brown", "Young", "Hamilton", "Bell", "Park", + * "Li") are left out, so "Wilson disease" and "Young Mania Rating Scale" pass. + */ +const SURNAMES: ReadonlySet = new Set( + ` +nguyen tran pham huynh hoang phan bui ngo duong vu vo dang +chen wang zhang liu yang huang zhao zhou xu zhu guo luo gao liang zheng xie cao deng +singh kaur patel sharma kumar gupta khan ahmed hussain rahman siddiqui +okafor obi diallo mensah haddad nasser khoury +kim lee choi kang cho yoon tanaka sato suzuki takahashi watanabe yamamoto nakamura kobayashi +smith jones taylor thompson anderson walker thomas jackson harris robinson clarke ryan kelly murphy +campbell stewart mitchell roberts johnson evans edwards davis +garcia rodriguez martinez lopez hernandez gonzalez silva santos ferreira costa rossi russo bianchi romano +ivanov petrov kowalski nowak muller schmidt fischer weber yilmaz +` + .split(/\s+/) + .filter(Boolean), +); + +/** Exported for the lexicon test only: the surnames that anchor a bare name. */ +export const BARE_NAME_SURNAMES: readonly string[] = [...SURNAMES]; + /** Exported for the lexicon test only: the given names a bare name must start with. */ export const BARE_NAME_GIVEN_NAMES: readonly string[] = [...GIVEN_NAMES]; @@ -82,6 +126,8 @@ const EPONYM_SURNAMES: ReadonlySet = new Set([ "freud", "jung", "maudsley", + "magenis", + "cade", ]); /** @@ -103,6 +149,13 @@ const KNOWN_PAIRS: readonly string[] = [ "charles darwin", "thomas embling", "graham farmer", + "john cade", + "mary jane", + "sally anne", + "lee silverman", + "miller fisher", + "kurt schneider", + "marsha linehan", ]; /** Words that name a place, a service or a tool rather than finish a person's name ("Alma Street", "Marian Centre"). */ @@ -212,7 +265,7 @@ const FUNCTION_WORDS: ReadonlySet = new Set([ * Gairdner", "King Edward", "Princess Margaret", "Lake Joondalup"). The pair is read past. */ const PLACE_PREFIX = - /(?= 4 && surnameWord.length >= 3; +/** True when the word could finish a name beside a given name or a listed surname. */ +function couldBeNamePart(word: string): boolean { + const folded = fold(word); + return ( + folded.length >= 2 && !EPONYM_SURNAMES.has(folded) && !NOT_A_SURNAME.has(folded) && !FUNCTION_WORDS.has(folded) + ); +} + +/** Both words in the same case: "Sarah Jones", "sarah jones", or in capitals when both are words, not short capitals such as "ECT". */ +function sameNameCase(first: string, second: string): boolean { + const firstCase = caseOf(first); + if (firstCase !== caseOf(second)) return false; + if (firstCase === "capitalised" || firstCase === "lower") return true; + return firstCase === "upper" && first.length >= 4 && second.length >= 3; +} + +function readsAsName(first: string, second: string): boolean { + const secondWord = second.replace(/['’]s$/u, ""); + if (!sameNameCase(first, secondWord)) return false; + const firstFolded = fold(first); + const secondFolded = fold(secondWord); + // A given name and then a surname ("Sarah Jones"), or a listed surname on either side ("Nguyen Thi", "Xiaoling Chen"). + if (GIVEN_NAMES.has(firstFolded) && couldBeNamePart(secondWord)) return true; + if (SURNAMES.has(firstFolded) && couldBeNamePart(secondWord)) return true; + if (SURNAMES.has(secondFolded) && couldBeNamePart(first) && !SENTENCE_WORDS.has(firstFolded)) return true; return false; } +/** + * Words that start a question or sit before a name without being part of it ("Can Sarah", "Ask + * Jane", "Plain Jane"). A surname then a given name ("Testpatient Jane") is read only when the + * first word is not one of these. + */ +const SENTENCE_WORDS: ReadonlySet = new Set( + ` +can could should would will does did do is are was were what when where which why how give start stop tell ask +call see dear hi hello hey thanks thank ok okay plain calamity if then so also just only does +` + .split(/\s+/) + .filter(Boolean), +); + +/** A surname then a given name, both capitalised: "Testpatient Jane clozapine", "Hartley Jane". */ +function readsAsReversedName(first: string, second: string): boolean { + if (caseOf(first) !== "capitalised" || caseOf(second) !== "capitalised") return false; + if (!GIVEN_NAMES.has(fold(second))) return false; + const firstFolded = fold(first); + return ( + first.length >= 3 && couldBeNamePart(first) && !SENTENCE_WORDS.has(firstFolded) && !GIVEN_NAMES.has(firstFolded) + ); +} + +/** A capitalised given name in a shape that holds only a person: "Jane T.", "Jane (Testpatient)", "Jane's lithium". */ +const GIVEN_NAME_SHAPE = + /(? `document${between}`); +} + const OPTIONS = { allowCapitals: true } as const; /** What in a Clinical question looks like a patient detail; null when it reads as safe to send. */ -export function clinicalQuestionPatientDetail(text: string): PatientDetailProblem | null { - if (!text.trim()) return null; +export function clinicalQuestionPatientDetail(typed: string): PatientDetailProblem | null { + if (!typed.trim()) return null; + const text = withoutPublicNumbersAndDocumentDates(typed); if (identifierShapeWarning(text)) { return { title: "This looks like an identifier", diff --git a/tests/clinical-question-patient-check.test.ts b/tests/clinical-question-patient-check.test.ts index 516d1b6847..f455a59fe4 100644 --- a/tests/clinical-question-patient-check.test.ts +++ b/tests/clinical-question-patient-check.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { BARE_NAME_GIVEN_NAMES } from "@/lib/clinical-question-bare-name"; +import { BARE_NAME_GIVEN_NAMES, BARE_NAME_SURNAMES } from "@/lib/clinical-question-bare-name"; import { clinicalQuestionPatientDetail, clinicalQuestionPatientDetailMessage, @@ -226,7 +226,9 @@ describe("clinicalQuestionPatientDetail: bare names and street addresses", () => }); it("starts no name with a medicine in the medication lexicon", () => { - const medicines = BARE_NAME_GIVEN_NAMES.filter((name) => medicationEntitiesInText(name).length > 0); + const medicines = [...BARE_NAME_GIVEN_NAMES, ...BARE_NAME_SURNAMES].filter( + (name) => medicationEntitiesInText(name).length > 0, + ); expect(medicines).toEqual([]); }); @@ -236,3 +238,86 @@ describe("clinicalQuestionPatientDetail: bare names and street addresses", () => } }); }); + +// Privacy audit PD-01 and PD-02 (2026-10-26). Every name, number and date here is invented. +describe("clinicalQuestionPatientDetail: wider names (PD-01)", () => { + it.each([ + // Given names beyond the English list. + "Minh Pham lithium level", + "Arjun Patel olanzapine weight", + "Ngozi Okafor clozapine", + "Aroha Ngata risk assessment", + "Kirra Hill depot overdue", + "Fatima Haddad postpartum", + "Mary Walker lithium", + "mary walker lithium", + // A listed surname on either side, including surname first. + "Nguyen Thi Lan clozapine", + "Chen Wei olanzapine", + "Xiaoling Chen lithium", + // A surname then a given name. + "Testpatient Jane clozapine", + // A given name in a shape that holds only a person. + "Jane T. olanzapine", + "Jane (Testpatient) lithium", + "Jane's lithium is 1.4", + // Senior titles and a lower-case title with one letter. + "Prof Testpatient lithium", + "Sr Testpatient clozapine", + "mrs t on lithium", + ])("refuses %s", (question) => { + const problem = clinicalQuestionPatientDetail(question); + expect(problem, question).not.toBeNull(); + expect(problem!.title).toContain("a name"); + }); + + it.each([ + "Mary Jane cannabis slang", + "Sally Anne test theory of mind", + "John Cade lithium discovery", + "Lee Silverman voice treatment", + "Miller Fisher syndrome", + "Marsha Linehan DBT", + "Kurt Schneider first rank symptoms", + "Royal Alexandra Hospital", + "Wilson disease copper psychosis", + "Williams syndrome anxiety", + "Young Mania Rating Scale", + "Hamilton Depression Rating Scale", + "Smith-Magenis syndrome behaviour", + "Stevens-Johnson syndrome lamotrigine", + "Edwards Syndrome", + "Chen et al. 2019 meta-analysis", + "Can Clozapine Cause Myocarditis", + "What Is The Starting Dose Of Lithium", + "QTc 480 ms a concern", + "Prof Kraepelin dementia praecox", + ])("reads %s as safe", (question) => { + expect(clinicalQuestionPatientDetail(question)).toBeNull(); + }); +}); + +describe("clinicalQuestionPatientDetail: public numbers and document dates (PD-02)", () => { + it.each([ + "Mental Health Emergency Response Line 1300 555 788", + "Kids Helpline 1800 55 1800", + "Lifeline 13 11 14", + "clozapine protocol updated 01/03/2024", + "Chief Psychiatrist guideline 15/06/2023", + "clozapine guideline version 2.1 dated 03/04/2025", + "lithium policy reviewed 12/08/2022", + ])("reads %s as safe", (question) => { + expect(clinicalQuestionPatientDetail(question)).toBeNull(); + }); + + it.each([ + "call her on 0412 345 678 about lithium", + "landline 08 9123 4567 clozapine", + "protocol DOB 01/02/1990 lithium", + "guideline born 01/02/1990", + "DOB: 01/02/1990 lithium", + "seen 01/02/2024 lithium level", + ])("still refuses %s", (question) => { + expect(clinicalQuestionPatientDetail(question), question).not.toBeNull(); + }); +}); From c9261d985bbb80120df719e7775e0d357d92df87 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:24:30 +0000 Subject: [PATCH 2/6] fix(privacy): check every search box before typed text leaves the page Privacy audit S1 (2026-10-26): - D26-01, CA-02, CA-05: Medication and differentials typing-pause fetches await the patient-detail check first, so a name never reaches our server. - CA-01: suggested, recent and cross-mode Medication searches and the Services, Forms and Favourites chips run the check before navigating. - CA-03: Tools, launcher, Calculators, On Call Refer link, Dictionary menu and the Dictionary topic box leave a patient detail out of ?q=. - CA-04: the search guard fails closed while the full check loads or when it cannot load. - CA-05: shared links with a patient detail in ?q= are cleaned on arrival on every search page. - CA-06: Referrer-Policy strict-origin. - CA-08: safety plan and My Day note fields opt out of autofill, spelling and grammar extensions. - Synthetic corpus floors for the widened name check. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EyULT41weiAuu3CroGX7oD --- src/components/ClinicalDashboard.tsx | 15 + src/components/applications-launcher-page.tsx | 13 + src/components/calculators/search-page.tsx | 9 +- .../clinical-dashboard/differentials-home.tsx | 8 +- .../global-search-shell.tsx | 21 + .../master-search-header-actions.ts | 5 +- .../search-patient-detail-guard.ts | 52 +- .../use-dashboard-search-execution.ts | 23 +- .../use-differential-catalog.ts | 39 +- .../use-medication-catalog.ts | 17 +- .../dictionary/dictionary-catalogue-pages.tsx | 11 +- .../my-day/my-day-work-me-cards.tsx | 5 + src/components/on-call/refer/refer-page.tsx | 11 +- src/components/patient-safety-plan.tsx | 10 + .../tools/tools-search-results-page.tsx | 16 +- src/lib/security-headers.ts | 4 +- tests/catalog-refetch-privacy.dom.test.tsx | 9 +- tests/fixtures/patient-detail-corpus.ts | 552 ++++++++++++++++++ tests/patient-detail-corpus.test.ts | 50 ++ tests/search-box-patient-detail.dom.test.tsx | 188 ++++++ tests/search-patient-detail-guard.test.ts | 75 ++- tests/security-headers.test.ts | 2 + 22 files changed, 1098 insertions(+), 37 deletions(-) create mode 100644 tests/fixtures/patient-detail-corpus.ts create mode 100644 tests/patient-detail-corpus.test.ts create mode 100644 tests/search-box-patient-detail.dom.test.tsx diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index a0043c5a61..ca641d8307 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -96,6 +96,7 @@ import { DashboardSearchErrorState } from "@/components/clinical-dashboard/dashb import { useDashboardShellActions } from "@/components/clinical-dashboard/use-dashboard-shell-actions"; import { useDashboardSearchExecution } from "@/components/clinical-dashboard/use-dashboard-search-execution"; import { + checkSearchTextPatientDetail, searchTextPatientDetailRefusal, usePreloadSearchPatientCheck, } from "@/components/clinical-dashboard/search-patient-detail-guard"; @@ -1208,6 +1209,20 @@ function ClinicalDashboardContent({ }); return; } + // Every other mode writes the text into the address bar, history and recent searches too, + // so a patient detail is held back here as well (privacy audit CA-01, 2026-10-26). + if (crossQuery.trim()) { + const submittedSeq = searchRequestSeqRef.current; + void checkSearchTextPatientDetail(crossQuery).then((refusal) => { + if (submittedSeq !== searchRequestSeqRef.current) return; + if (refusal) { + setActionNotice({ tone: "warning", message: refusal }); + return; + } + latestNavigateCrossModeSearch(mode, crossQuery); + }); + return; + } navigateCrossModeSearch(mode, crossQuery); } diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index 733e770316..36a025d006 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -1,5 +1,10 @@ "use client"; +import { + searchTextPatientDetailRefusal, + usePreloadSearchPatientCheck, +} from "@/components/clinical-dashboard/search-patient-detail-guard"; +import { useOptionalToast } from "@/components/ui/toast"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { @@ -711,6 +716,7 @@ export function ApplicationsLauncherWorkspace({ canAccessFavourites: canAccessFavouritesProp, }: ApplicationsLauncherWorkspaceProps) { const router = useRouter(); + const pushToast = useOptionalToast()?.push; const auth = useAuthSession(); const clientDemoMode = resolveClientDemoMode({ explicitDemoMode: process.env.NEXT_PUBLIC_DEMO_MODE === "true", @@ -727,6 +733,7 @@ export function ApplicationsLauncherWorkspace({ const launcherApps = useMemo(() => launcherAppsForSession(canAccessFavourites), [canAccessFavourites]); const desktopFilters = useMemo(() => desktopFiltersForSession(canAccessFavourites), [canAccessFavourites]); const query = localQuery ?? controlledQuery ?? searchCommand?.query ?? ""; + usePreloadSearchPatientCheck(query); const normalizedQuery = query.trim().toLowerCase(); const naturalSmartSearch = useMemo(() => interpretSmartSearch("tools", query).naturalLanguage, [query]); const smartExpansions = useMemo(() => smartSearchExpansions("tools", query), [query]); @@ -787,6 +794,12 @@ export function ApplicationsLauncherWorkspace({ function submitSearch() { const submittedQuery = query.trim(); if (submittedQuery) { + // Text that looks like it holds a patient detail stays in the box, not the address (privacy audit CA-03). + const refusal = searchTextPatientDetailRefusal(submittedQuery); + if (refusal) { + pushToast?.({ tone: "warning", title: refusal }); + return; + } router.push(`/tools?q=${encodeURIComponent(submittedQuery)}&run=1`); return; } diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index b76c836569..eb1b356e34 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -1,5 +1,9 @@ "use client"; +import { + searchTextPatientDetailRefusal, + usePreloadSearchPatientCheck, +} from "@/components/clinical-dashboard/search-patient-detail-guard"; import { useRouter, useSearchParams } from "next/navigation"; import { ArrowRight, Clock3, History, Info, LayoutGrid, ListChecks, Rows3, Search, Sigma } from "lucide-react"; import { useCallback, useEffect, useId, useMemo, useState, useSyncExternalStore } from "react"; @@ -330,6 +334,7 @@ export function CalculatorsSearchPage({ () => false, ); const query = hydrated ? (searchCommand?.query ?? initialQuery) : initialQuery; + usePreloadSearchPatientCheck(query); const normalizedQuery = normalizeCalculatorQuery(query); const smartExpansions = useMemo(() => smartSearchExpansions("calculators", query), [query]); const filterPanelId = useId(); @@ -382,7 +387,7 @@ export function CalculatorsSearchPage({ const nextParams = new URLSearchParams( searchParams ? searchParams.toString() : typeof window !== "undefined" ? window.location.search : "", ); - if (!nextParams.has("q") && query.trim()) { + if (!nextParams.has("q") && query.trim() && !searchTextPatientDetailRefusal(query)) { nextParams.set("q", query.trim()); } nextParams.set("calculator", calculator.id); @@ -394,7 +399,7 @@ export function CalculatorsSearchPage({ searchParams ? searchParams.toString() : typeof window !== "undefined" ? window.location.search : "", ); nextParams.delete("calculator"); - if (!nextParams.has("q") && query.trim()) { + if (!nextParams.has("q") && query.trim() && !searchTextPatientDetailRefusal(query)) { nextParams.set("q", query.trim()); } const searchString = nextParams.toString(); diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 178940df99..c1587292ed 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -1115,12 +1115,14 @@ function SearchResultsView({ faultTitle={ catalog.status === "unauthorized" ? "Sign in again to search the differentials catalogue" - : "The differentials catalogue could not be searched" + : catalog.heldBack + ? "This search was not sent" + : "The differentials catalogue could not be searched" } faultBody={ catalog.status === "unauthorized" ? "Sign in again to search, or browse the catalogue pages directly." - : "Retry the search shortly, or browse the catalogue pages directly." + : (catalog.heldBack ?? "Retry the search shortly, or browse the catalogue pages directly.") } // The fault copy promises two recoveries, so both have to exist: rerun the // search, and the catalogue links that the removed error section used to @@ -1131,7 +1133,7 @@ function SearchResultsView({ // parent document-evidence search; the catalogue hook keys on query + auth // identity, neither of which changes when the reader asks to try again, so // routing Retry through it left the band permanently faulted. - onRetry={catalog.status === "unauthorized" ? undefined : catalog.refetch} + onRetry={catalog.status === "unauthorized" || catalog.heldBack ? undefined : catalog.refetch} faultAction={ <> { + if (!requestedQuery || pathname === "/") return undefined; + let cancelled = false; + void checkSearchTextPatientDetail(requestedQuery).then((refusal) => { + if (cancelled || !refusal) return; + const url = new URL(window.location.href); + if ((url.searchParams.get("q") ?? url.searchParams.get("query") ?? "").trim() !== requestedQuery) return; + url.searchParams.delete("q"); + url.searchParams.delete("query"); + url.searchParams.delete("run"); + pushToast?.({ tone: "warning", title: refusal }); + router.replace(`${url.pathname}${url.search}${url.hash}`, { scroll: false }); + }); + return () => { + cancelled = true; + }; + }, [pathname, pushToast, requestedQuery, router]); const deferredQuery = useDeferredValue(query); const [, setLastAppMode] = useLastAppMode(); // Previous URL snapshot for during-render sync (React "adjusting state when a diff --git a/src/components/clinical-dashboard/master-search-header-actions.ts b/src/components/clinical-dashboard/master-search-header-actions.ts index 2b5da2c90d..e49d146522 100644 --- a/src/components/clinical-dashboard/master-search-header-actions.ts +++ b/src/components/clinical-dashboard/master-search-header-actions.ts @@ -1,6 +1,7 @@ import type { AppModeId } from "@/lib/app-modes"; import type { ClinicalQueryMode } from "@/lib/types"; import type { ModeActionId } from "@/components/clinical-dashboard/mode-action-popup"; +import { searchTextPatientDetailRefusal } from "@/components/clinical-dashboard/search-patient-detail-guard"; export interface MasterSearchHeaderModeActionContext { actionId: ModeActionId; @@ -112,7 +113,9 @@ export function executeMasterSearchHeaderModeAction({ return; } if (actionId === "dictionary-search") { - pushIfLeaveConfirmed(`/dictionary/search${trimmedQuery ? `?q=${encodeURIComponent(trimmedQuery)}` : ""}`); + // Text that looks like it holds a patient detail is left out of the address (privacy audit CA-03). + const carried = trimmedQuery && !searchTextPatientDetailRefusal(trimmedQuery) ? trimmedQuery : ""; + pushIfLeaveConfirmed(`/dictionary/search${carried ? `?q=${encodeURIComponent(carried)}` : ""}`); return; } if (actionId === "dictionary-topics") { diff --git a/src/components/clinical-dashboard/search-patient-detail-guard.ts b/src/components/clinical-dashboard/search-patient-detail-guard.ts index d290bc2e55..c9f2e1a491 100644 --- a/src/components/clinical-dashboard/search-patient-detail-guard.ts +++ b/src/components/clinical-dashboard/search-patient-detail-guard.ts @@ -9,6 +9,13 @@ type PatientCheckModule = typeof import("@/lib/clinical-question-patient-check") export const SEARCH_IDENTIFIER_HELD_BACK = "This search looks like it holds a record number, date of birth, phone number or email. Remove it and search again."; +/** Held back while the full check is still loading: a name is not let through unchecked (privacy audit CA-04). */ +export const SEARCH_CHECK_LOADING = "Checking this search for patient details. Try again in a moment."; + +/** Held back when the full check could not load (a dropped connection); the next search tries the download again. */ +export const SEARCH_CHECK_UNAVAILABLE = + "Could not check this search for patient details. Check your connection and try again."; + let loadedPatientCheck: PatientCheckModule | null = null; let pendingPatientCheck: Promise | null = null; @@ -40,7 +47,10 @@ export function usePreloadSearchPatientCheck(text: string) { * The held-back sentence when search text looks like it holds a patient detail, else null. * Navigation that writes typed text into a page address (`?q=`) calls this first, so the * detail never reaches the address bar, browser history or a request line (design audit - * D-03). Uses the full check once it has loaded, and the identifier-shape check until then. + * D-03). Uses the full check once it has loaded. Until then it fails closed (privacy audit + * CA-04, 2026-10-26): an identifier shape gets its own sentence, and anything else is held + * back as still being checked while the full check loads, so a name never slips through in + * that window. */ export function searchTextPatientDetailRefusal(text: string): string | null { const trimmed = text.trim(); @@ -49,5 +59,43 @@ export function searchTextPatientDetailRefusal(text: string): string | null { const problem = loadedPatientCheck.clinicalQuestionPatientDetail(trimmed); return problem ? loadedPatientCheck.searchPatientDetailMessage(problem) : null; } - return identifierShapeWarning(trimmed) ? SEARCH_IDENTIFIER_HELD_BACK : null; + void preloadSearchPatientCheck(); + return identifierShapeWarning(trimmed) ? SEARCH_IDENTIFIER_HELD_BACK : SEARCH_CHECK_LOADING; +} + +/** + * The same check for a caller that can wait: it waits for the full check to load, then reads + * the text with it. A check that cannot load holds the search back (fails closed). Used before + * a search request leaves the page, such as a typing-pause fetch or a deep link on arrival + * (privacy audit CA-02 and CA-05). + */ +export async function checkSearchTextPatientDetail(text: string): Promise { + const trimmed = text.trim(); + if (!trimmed) return null; + await preloadSearchPatientCheck(); + if (!loadedPatientCheck) + return identifierShapeWarning(trimmed) ? SEARCH_IDENTIFIER_HELD_BACK : SEARCH_CHECK_UNAVAILABLE; + return searchTextPatientDetailRefusal(trimmed); +} + +/** Thrown inside a search request chain when the check holds the text back; its message is the held-back sentence. */ +export class SearchHeldBack extends Error {} + +/** + * For a box that writes each keystroke into the page address: the text is written only once + * the check passes. Text held back clears the address's copy and says why (privacy audit CA-03). + */ +export function writeCheckedSearchText( + text: string, + write: (value: string) => void, + warn: (message: string) => void, +): void { + void checkSearchTextPatientDetail(text).then((refusal) => { + if (!refusal) { + write(text); + return; + } + write(""); + warn(refusal); + }); } diff --git a/src/components/clinical-dashboard/use-dashboard-search-execution.ts b/src/components/clinical-dashboard/use-dashboard-search-execution.ts index 837fb02541..f283ee5ccd 100644 --- a/src/components/clinical-dashboard/use-dashboard-search-execution.ts +++ b/src/components/clinical-dashboard/use-dashboard-search-execution.ts @@ -47,7 +47,11 @@ import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract"; import { createAnswerThreadSnapshotMetadata, maxStoredAnswerTurns } from "@/lib/answer-thread-storage"; import { appModeHomeHref, appModeQueryMode, appModeSearchConfig, type AppModeId } from "@/lib/app-modes"; import { identifierShapeWarning } from "@/lib/clinical-ask/context"; -import { SEARCH_IDENTIFIER_HELD_BACK } from "@/components/clinical-dashboard/search-patient-detail-guard"; +import { + SEARCH_CHECK_UNAVAILABLE, + SEARCH_IDENTIFIER_HELD_BACK, + searchTextPatientDetailRefusal, +} from "@/components/clinical-dashboard/search-patient-detail-guard"; import { incrementalEvidencePreviewRenderingEnabled } from "@/lib/client-env"; import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { documentsSearchHref } from "@/lib/document-flow-routes"; @@ -845,9 +849,16 @@ export function useDashboardSearchExecution(deps: DashboardSearchExecutionDeps) } function setMedicationSearchQuery(searchText: string, updateUrl = true) { - modeChangeFromUiRef.current = true; const trimmedSearchText = searchText.trim(); if (!trimmedSearchText) return; + // A suggested, recent or cross-mode Medication search writes the text into the address bar, + // history and recent searches, so it gets the same check as a typed one (privacy audit CA-01). + const refusal = searchTextPatientDetailRefusal(trimmedSearchText); + if (refusal) { + setActionNotice({ tone: "warning", message: refusal }); + return; + } + modeChangeFromUiRef.current = true; setSearchMode("prescribing"); setQuery(trimmedSearchText); setModeSearchSubmitted(true, trimmedSearchText); @@ -897,10 +908,10 @@ export function useDashboardSearchExecution(deps: DashboardSearchExecutionDeps) } } else if (sendsQueryToProvider) { refusal = PATIENT_CHECK_UNAVAILABLE; - } else if (identifierShapeWarning(trimmedQuery)) { - // Searches that stay on our servers still go ahead when the full check cannot load, - // but never with a record number, date of birth, phone number or email in them. - refusal = SEARCH_IDENTIFIER_HELD_BACK; + } else { + // Searches that stay on our servers are held back too when the full check cannot load, + // since a name would otherwise reach the address bar and history (privacy audit CA-04). + refusal = identifierShapeWarning(trimmedQuery) ? SEARCH_IDENTIFIER_HELD_BACK : SEARCH_CHECK_UNAVAILABLE; } if (refusal) { setActionNotice({ tone: "warning", message: refusal }); diff --git a/src/components/clinical-dashboard/use-differential-catalog.ts b/src/components/clinical-dashboard/use-differential-catalog.ts index 0c774e6b57..ba0ca6cee4 100644 --- a/src/components/clinical-dashboard/use-differential-catalog.ts +++ b/src/components/clinical-dashboard/use-differential-catalog.ts @@ -1,5 +1,9 @@ "use client"; +import { + checkSearchTextPatientDetail, + SearchHeldBack, +} from "@/components/clinical-dashboard/search-patient-detail-guard"; import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { authSessionFingerprint, createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; @@ -23,6 +27,8 @@ export type DifferentialSearchState = { * EITHER request degraded — a reader cannot tell which half of a merged * result set a given row came from, so the caveat has to cover both. */ degraded: boolean; + /** Set with status "error" when the typed text was held back for a patient detail and never sent; the sentence to show. */ + heldBack?: string; }; const emptyDifferentialMatches: DifferentialSearchMatches = { diagnoses: [], presentations: [] }; @@ -208,16 +214,21 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { const isCurrentRequest = () => requestLifecycle.isCurrent(registration.epoch); const timer = window.setTimeout(() => { const encoded = encodeURIComponent(requestKey); - Promise.all([ - fetch(`/api/differentials?kind=diagnosis&q=${encoded}&limit=20`, { - headers: authorizationHeader, - signal: controller.signal, - }), - fetch(`/api/differentials?kind=presentation&q=${encoded}&limit=10`, { - headers: authorizationHeader, - signal: controller.signal, - }), - ]) + // Typed text is checked for patient details before it leaves the page (privacy audit CA-02). + checkSearchTextPatientDetail(requestKey) + .then((refusal) => { + if (refusal) throw new SearchHeldBack(refusal); + return Promise.all([ + fetch(`/api/differentials?kind=diagnosis&q=${encoded}&limit=20`, { + headers: authorizationHeader, + signal: controller.signal, + }), + fetch(`/api/differentials?kind=presentation&q=${encoded}&limit=10`, { + headers: authorizationHeader, + signal: controller.signal, + }), + ]); + }) .then(async ([diagnosisResponse, presentationResponse]) => { if (controller.signal.aborted || !isCurrentRequest()) return; if (diagnosisResponse.status === 401 || presentationResponse.status === 401) { @@ -262,7 +273,13 @@ export function useDifferentialSearch(query: string): DifferentialSearchResult { (error instanceof DOMException && error.name === "AbortError") ) return; - setState({ status: "error", matches: emptyDifferentialMatches, demoMode: false, degraded: false }); + setState({ + status: "error", + matches: emptyDifferentialMatches, + demoMode: false, + degraded: false, + ...(error instanceof SearchHeldBack ? { heldBack: error.message } : {}), + }); }); }, debounceMs); diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts index d68a1a287f..8820f19bf0 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -6,6 +6,10 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react import { authSessionFingerprint, createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; import type { MedicationRecord, MedicationSearchResult } from "@/lib/medications"; import { useAuthSession } from "@/lib/supabase/client"; +import { + checkSearchTextPatientDetail, + SearchHeldBack, +} from "@/components/clinical-dashboard/search-patient-detail-guard"; type MedicationCatalogMatch = { medication: MedicationRecord; @@ -309,11 +313,22 @@ export function useMedicationCatalog( timedOut = true; controller.abort(); }, MEDICATION_SEARCH_DEADLINE_MS); - fetchJson(url, authorizationHeader, controller.signal, parseMedicationCatalogResponse) + // Typed text is checked for patient details before it leaves the page, on every typing + // pause and on a deep link's first request (privacy audits D26-01, CA-02 and CA-05). + checkSearchTextPatientDetail(trimmed) + .then((refusal) => { + if (refusal) throw new SearchHeldBack(refusal); + return fetchJson(url, authorizationHeader, controller.signal, parseMedicationCatalogResponse); + }) .then((data) => { if (!controller.signal.aborted && isCurrentRequest()) setState({ data, loading: false, error: null }); }) .catch((error) => { + if (error instanceof SearchHeldBack) { + if (!controller.signal.aborted && isCurrentRequest()) + setState({ data: null, loading: false, error: error.message }); + return; + } if (timedOut && isCurrentRequest()) { setState({ data: null, loading: false, error: medicationCatalogErrorMessage(error, true) }); return; diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx index 19015105b4..ff7e22cc9b 100644 --- a/src/components/dictionary/dictionary-catalogue-pages.tsx +++ b/src/components/dictionary/dictionary-catalogue-pages.tsx @@ -22,6 +22,7 @@ import { resultFilterGroup, type ResultFilterOption, } from "@/components/clinical-dashboard/result-filter-control"; +import { writeCheckedSearchText } from "@/components/clinical-dashboard/search-patient-detail-guard"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; @@ -37,6 +38,7 @@ import { } from "@/components/information-page-shell"; import { PageHeader } from "@/components/ui/page-header"; import { SegmentedControl } from "@/components/ui/segmented-control"; +import { useOptionalToast } from "@/components/ui/toast"; import { cn, searchShell, searchShellInput } from "@/components/ui-primitives"; import { allDictionaryEntries, @@ -915,6 +917,7 @@ export function DictionaryTopicDetailPage({ topicSlug }: { topicSlug: string }) const router = useRouter(); const sectionNav = useInPageSectionNav(topicDetailSections); const { searchParams, replace, setOne, toggleMany } = useDictionaryUrl(); + const pushToast = useOptionalToast()?.push; const serializedParams = searchParams.toString(); const query = (searchParams.get("q") ?? "").trim().toLocaleLowerCase(); const deferredQuery = useDeferredValue(query); @@ -1023,7 +1026,13 @@ export function DictionaryTopicDetailPage({ topicSlug }: { topicSlug: string }) Search this topic setOne("q", event.target.value)} + onChange={(event) => + writeCheckedSearchText( + event.target.value, + (value) => setOne("q", value), + (message) => pushToast?.({ tone: "warning", title: message }), + ) + } placeholder="Search this topic" className={cn(searchShellInput, "text-base sm:text-sm")} /> diff --git a/src/components/my-day/my-day-work-me-cards.tsx b/src/components/my-day/my-day-work-me-cards.tsx index 722b128757..e206f7791f 100644 --- a/src/components/my-day/my-day-work-me-cards.tsx +++ b/src/components/my-day/my-day-work-me-cards.tsx @@ -928,6 +928,11 @@ export function QuickNoteCard() { aria-invalid={problem ? true : undefined} data-testid="my-day-quick-note" placeholder="A reminder for yourself" + // Kept from autofill, spelling and grammar extensions, like the other note fields (privacy audit CA-08). + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" className="min-h-14 w-full resize-y rounded-lg border border-[color:var(--work-line-strong)] bg-[color:var(--work-surface)] px-3 py-2.5 text-sm text-[color:var(--work-ink)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[color:var(--focus)] forced-colors:border" />
diff --git a/src/components/on-call/refer/refer-page.tsx b/src/components/on-call/refer/refer-page.tsx index 118c846812..8ae8ed0a67 100644 --- a/src/components/on-call/refer/refer-page.tsx +++ b/src/components/on-call/refer/refer-page.tsx @@ -1,5 +1,9 @@ "use client"; +import { + searchTextPatientDetailRefusal, + usePreloadSearchPatientCheck, +} from "@/components/clinical-dashboard/search-patient-detail-guard"; import { ClipboardList, Feather, LayoutList } from "lucide-react"; import Link from "next/link"; import { useMemo, useState } from "react"; @@ -160,7 +164,12 @@ export function OnCallReferSections({ query }: { readonly query: string }) { const secondary = (item: HandbookItem) => item.parsed.team ? onCallTeamBarLabel(item.parsed.team) : handbookFirstLine(item.body); - const servicesHref = `/services/search?q=${encodeURIComponent(query.trim())}`; + // Text that looks like it holds a patient detail is left out of the link's address (privacy audit CA-03). + usePreloadSearchPatientCheck(query); + const servicesHref = + query.trim() && !searchTextPatientDetailRefusal(query) + ? `/services/search?q=${encodeURIComponent(query.trim())}` + : "/services/search"; const resultCount = shown.length + (signedOut ? 0 : mineShown.length); return ( diff --git a/src/components/patient-safety-plan.tsx b/src/components/patient-safety-plan.tsx index 3789ac3c4d..dc7dbff73a 100644 --- a/src/components/patient-safety-plan.tsx +++ b/src/components/patient-safety-plan.tsx @@ -370,6 +370,11 @@ function AddRow({ }} placeholder={primaryPlaceholder} aria-label={kind === "contact" ? `${label}: ${primaryPlaceholder}` : `${label}: new entry`} + // Kept from autofill, spelling and grammar extensions, like the other note fields (privacy audit CA-08). + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" className={cn(fieldControlPlain, "min-h-tap")} /> {kind === "contact" ? ( @@ -390,6 +395,10 @@ function AddRow({ }} placeholder={secondaryPlaceholder} aria-label={`${label}: ${secondaryPlaceholder}`} + autoComplete="off" + spellCheck={false} + data-gramm="false" + data-enable-grammarly="false" className={cn(fieldControlPlain, "min-h-tap")} /> ) : null} @@ -1132,6 +1141,7 @@ export function PatientSafetyPlan() { setFinalised(false); }} placeholder="e.g. 12 Aug 2026" + autoComplete="off" className={fieldControlPlain} />
diff --git a/src/components/tools/tools-search-results-page.tsx b/src/components/tools/tools-search-results-page.tsx index 081b34ddd9..156f45b649 100644 --- a/src/components/tools/tools-search-results-page.tsx +++ b/src/components/tools/tools-search-results-page.tsx @@ -1,5 +1,10 @@ "use client"; +import { + searchTextPatientDetailRefusal, + usePreloadSearchPatientCheck, +} from "@/components/clinical-dashboard/search-patient-detail-guard"; +import { useOptionalToast } from "@/components/ui/toast"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ClipboardList, Info, Pin, PinOff, Search, ShieldCheck, Waves, type LucideIcon } from "lucide-react"; @@ -349,6 +354,7 @@ export function ToolsSearchResultsPage({ () => false, ); const router = useRouter(); + const pushToast = useOptionalToast()?.push; const { pinnedToolIds, togglePinnedTool } = useToolPins(); // A draft typed into this page's own search box wins over everything else. Below // that: the route's submitted query so hard loads server-render the exact result @@ -356,6 +362,7 @@ export function ToolsSearchResultsPage({ // cleared value, until the next submitted navigation. const [localQuery, setLocalQuery] = useState(null); const query = localQuery ?? (hydrated ? (searchCommand?.query ?? initialQuery) : initialQuery); + usePreloadSearchPatientCheck(query); const searching = query.trim().length > 0; // The sheet keeps its last tool after closing so the close animation has content. const aboutTitleId = useId(); @@ -403,7 +410,14 @@ export function ToolsSearchResultsPage({ // shareable and survives reload. An empty box has nothing to submit. function submitLocalSearch() { const submittedQuery = query.trim(); - if (submittedQuery) router.push(`/tools?q=${encodeURIComponent(submittedQuery)}&run=1`); + if (!submittedQuery) return; + // Text that looks like it holds a patient detail stays in the box, not the address (privacy audit CA-03). + const refusal = searchTextPatientDetailRefusal(submittedQuery); + if (refusal) { + pushToast?.({ tone: "warning", title: refusal }); + return; + } + router.push(`/tools?q=${encodeURIComponent(submittedQuery)}&run=1`); } return ( diff --git a/src/lib/security-headers.ts b/src/lib/security-headers.ts index 244c263d39..9fd4c60ab6 100644 --- a/src/lib/security-headers.ts +++ b/src/lib/security-headers.ts @@ -133,7 +133,9 @@ export function buildSecurityHeaders(flags: SecurityHeaderFlags): SecurityHeader return [ { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "DENY" }, - { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + // Origin only, even within the site: a full page address can carry search text in ?q=, + // which strict-origin-when-cross-origin would send on every same-site request (privacy audit CA-06). + { key: "Referrer-Policy", value: "strict-origin" }, { key: "Cross-Origin-Resource-Policy", value: "same-site" }, { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, // No Cross-Origin-Embedder-Policy — see module header note. diff --git a/tests/catalog-refetch-privacy.dom.test.tsx b/tests/catalog-refetch-privacy.dom.test.tsx index d7902af9ba..17e8bf9a1a 100644 --- a/tests/catalog-refetch-privacy.dom.test.tsx +++ b/tests/catalog-refetch-privacy.dom.test.tsx @@ -1,6 +1,7 @@ import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { preloadSearchPatientCheck } from "@/components/clinical-dashboard/search-patient-detail-guard"; import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { useRegistryRecord, useRegistryRecords } from "@/lib/use-registry-records"; @@ -32,6 +33,12 @@ async function flushMicrotasks() { }); } +// The medication search checks typed text for patient details before it sends; load the check once +// up front so the fake-timer steps below see the request go out (privacy audit CA-02). +beforeAll(async () => { + await preloadSearchPatientCheck(); +}); + beforeEach(() => { authSession.authorizationHeader = { Authorization: "Bearer user-a-token" }; authSession.markSessionExpired.mockReset(); diff --git a/tests/fixtures/patient-detail-corpus.ts b/tests/fixtures/patient-detail-corpus.ts new file mode 100644 index 0000000000..3b86ba9c0f --- /dev/null +++ b/tests/fixtures/patient-detail-corpus.ts @@ -0,0 +1,552 @@ +// Synthetic corpus for measuring the patient-detail check (privacy audit PD-01 and PD-02, 2026-10-26). +// Every person, number and address here is invented. Medicare-like and IHI-like numbers use invalid +// check digits or reserved test prefixes. No real patient data. +// +// The first set is the audit's corpus. The held-out set was written before the check was widened. + +export const corpusIdentifiers: Record = { + "name-titled": [ + "Mr John Testpatient lithium level 1.4", + "Mrs Jane Testpatient on clozapine, ANC low", + "Ms Testpatient started olanzapine", + "Miss Amy Testcase self harm risk", + "Dr Testpatient patient on depot, missed dose", + "mr testpatient clozapine titration", + "MRS TESTPATIENT LITHIUM TOXICITY", + "Mr. J. Testpatient serotonin syndrome?", + "Mx Riley Testcase gender dysphoria assessment", + "Master Tom Testpatient ADHD dose", + ], + "name-bare-anglo": [ + "Sarah Jones lithium level 1.4", + "jane testpatient clozapine level", + "Michael Testcase olanzapine weight gain", + "Is it safe to give Emma Smith ECT", + "David Brown is on 20mg citalopram what about QTc", + "Can Peter Testpatient have lorazepam with clozapine", + "LUKE TESTCASE NMS", + "chloe testpatient eating disorder refeeding", + "Robert Testcase delirium haloperidol dose", + "Olivia Example quetiapine pregnancy", + ], + "name-bare-diverse": [ + "Wei Chen lithium level", + "Nguyen Van Testa clozapine", + "Priyanka Testpatel postpartum psychosis", + "Kaya Testmob First Nations liaison referral", + "Jayden Testwalker depot due", + "Mohammad Testrahimi ECT consent", + "Aroha Testtane risk assessment", + "Siobhan Testmurphy lithium", + "Tran Thi Testhoa olanzapine", + "Kwame Testasante psychosis first episode", + "Talia Testcohen anorexia nervosa admission", + "Rangi Testparata clozapine level", + ], + "name-surname-only": [ + "Testpatient's lithium level is 1.4, what now", + "testpatient on clozapine with fever", + "Can Testpatient have ECT", + "Smith in bed with NMS signs", + ], + "name-record-style": [ + "TESTPATIENT, Jane clozapine", + "TESTCASE, John - lithium", + "Testpatient J, olanzapine", + "Testpatient, Jane (01/02/1903)", + ], + "name-patient-word": [ + "patient John has lithium toxicity", + "pt Testpatient clozapine", + "client Jones refusing depot", + "pt js on clozapine", + "consumer Testcase wants discharge", + "my patient Sarah is pregnant on valproate", + ], + initials: [ + "JS 45M with psychosis on olanzapine", + "J.S. 45 year old male lithium", + "AB, 32F, first episode psychosis", + "pt JT 70F delirium", + "J. Testpatient serotonin syndrome", + "KL 19 M ADHD stimulant", + ], + "age-sex-plus-place": [ + "45 year old male bed 12 lithium toxicity", + "bed 4 ms smith clozapine", + "Ward 4N bed 12 Mrs Smith ANC", + "room 7 70F delirium", + "bay 3 the 45M with NMS", + "bed twelve clozapine titration", + ], + "dob-au": [ + "born 01/02/1903 on lithium", + "DOB 1/2/03 clozapine", + "d.o.b. 01.02.1903 lithium", + "DOB: 01-02-1903", + "date of birth 01/02/1903 olanzapine", + "01/02/1903 lithium level 1.4", + "pt born 1/2/1903", + "dob 010203 clozapine", + ], + "dob-iso": [ + "DOB 1903-02-01", + "born 1903-02-01 olanzapine", + "1903-02-01 clozapine level", + "date of birth: 1903-02-01", + ], + "dob-written": [ + "born 1 February 1903 lithium", + "DOB 1st Feb 1903", + "born on the 1st of February 1903, clozapine", + "date of birth February 1, 1903", + "b. 1 Feb 1903 olanzapine", + "born 1903 clozapine level", + ], + "record-wa": [ + "URN TEST000001 lithium", + "UMRN A1234567 clozapine", + "A1234567 olanzapine level", + "MRN 0012345 depot", + "UR 123456 lithium", + "UR: 7654321", + "urn test000001", + "D 467 8677 clozapine", + "Medical record number: TEST000001", + "hospital no. 9876543", + "PMI 1234567 lithium", + "patient id 99887766", + ], + "medicare-like": [ + "Medicare 2123 45670 1", + "medicare number 2123456701", + "2123 45670 1 lithium", + "2123456701", + "Medicare: 2123 456 701", + "medicare 2123-45670-1/1", + ], + "ihi-like": [ + "IHI 8003 6000 0000 0000", + "8003600000000000 clozapine", + "IHI: 8003-6000-0000-0000", + "ihi number 8003 6012 3456 7890", + ], + address: [ + "12 Testing St Perth lithium", + "Unit 3/45 Fake Road, Joondalup WA 6027", + "lives at 8 Smith Street", + "8 Smith St clozapine", + "PO Box 1234 Midland WA 6936", + "14 Example Avenue, Armadale", + "address 22 Test Crescent Mandurah", + "Lot 5 Example Rd Kalgoorlie 6430", + ], + phone: [ + "0412 345 678 call back", + "+61 8 9123 4567", + "(08) 9123 4567 lithium", + "0412345678", + "ph 9123 4567", + "mobile 04 1234 5678", + ], + email: ["jane.testpatient@example.com", "email testcase@example.org about lithium", "JANE@EXAMPLE.COM.AU"], + "mixed-clinical": [ + "Mr Test Patient bed 12 4N, lithium 1.4, what next?", + "Jane Testpatient born 01/02/1903 clozapine ANC 1.2", + "45M URN TEST000001 NMS management", + "Pt Testpatient 0412 345 678 wants to talk about depot", + "Sarah Jones 32F postpartum, lamotrigine dose?", + "Mrs Testpatient at 12 Testing St Perth needs CTO review", + "lithium 1.4 in John Testpatient aged 70", + "re: Testpatient, Jane DOB 01/02/1903 - clozapine restart", + ], + "name-bare-diverse-2": [ + "Ngozi Testokafor clozapine", + "Hiroshi Testtanaka lithium", + "Ananya Testsharma ECT", + "Mei Ling Testwong depression", + "Jarrah Testbennell psychosis", + "Kirra Testroberts self harm", + "Ali Testhassan olanzapine", + "Fatima Testali postpartum", + "Bao Testnguyen first episode", + "Dmitri Testivanov alcohol withdrawal", + "Giulia Testrossi lithium", + "Joao Testsilva clozapine", + "Aisha Testbello depot", + "Ravi Testkumar NMS", + "Lin Testzhang delirium", + "Yusuf Testahmed agitation", + "Tama Testwhaanga risk", + "Marlee Testwatson first nations", + "Bindi Testcollard ADHD", + "Mary Testpatient lithium", + ], + "name-surname-only-2": [ + "testpatient's clozapine level", + "is Testpatient ok on 400mg quetiapine", + "TESTPATIENT lithium toxic", + "for Testcase: depot overdue", + "Testpatient-Smith on olanzapine", + "mrs t on lithium", + ], + "name-other-forms": [ + "Jane's lithium is 1.4", + "Jane Smith-Testpatient clozapine", + "dose for Jane Testpatient", + "his mother Jane Testpatient called", + "Prof Testpatient lithium", + "Sr Testpatient clozapine", + "Jane T. olanzapine", + "J Testpatient lithium", + "Testpatient Jane clozapine", + "Jane (Testpatient) lithium", + ], + "other-govt-numbers": [ + "DVA WX123456 lithium", + "DVA file number W 123456", + "CRN 123 456 789A", + "NDIS 430 123 456", + "NDIS number 430123456", + "Medicare 2123 45670 1 IRN 1", + "driver licence 1234567", + "passport PA1234567", + ], + "dob-more": [ + "DOB 1.2.03", + "born 01 02 1903", + "born in 1903 clozapine", + "dob 1/2", + "D.O.B 1903/02/01", + "birthday 1 Feb", + "82yo born 1943", + "DOB 01FEB1903", + ], + "record-more": [ + "MRN:1234567", + "URN#1234567", + "UMRN B 123 4567", + "UR no 0098765", + "WebPAS 1234567", + "PSOLIS 1234567", + "BOSSnet 1234567", + "TOPAS 1234567", + ], + "location-and-dates": [ + "SCGH ward G52 bed 3 NMS", + "bed 12A clozapine", + "admitted 12/10/2026 to Graylands", + "seen in ED on 25/10/2026 at 2300", + "bay 3 bed 2", + "room 14 lithium", + ], + "quasi-identifiers": [ + "the 92 year old from Wiluna on clozapine", + "only Aboriginal woman on the ward with NMS", + "MLA for Testtown admitted with mania", + "Testtown footballer who was on the news, psychosis", + "the teacher at Test Primary School on lithium", + "rego 1ABC234 ute driver psychosis", + "lives in 6430 aged 34 on clozapine", + ], + "phone-email-more": [ + "call 9123 4567", + "0412-345-678", + "+61412345678", + "jane underscore testpatient at example dot com", + "j.testpatient@health.wa.gov.au", + "@testpatient on instagram", + ], + "address-more": [ + "7 Test Way Rockingham", + "45 Example Pde Scarborough", + "Flat 2, 10 Test Lane, Subiaco 6008", + "lives on Test Street Bunbury", + "12 Testing Street, Perth WA 6000", + "123 Fake Highway Albany", + ], + obfuscated: [ + "M​r Testpatient lithium", + "Mrs Testpatient clozapine", + "Мrs Testpatient olanzapine", + "J o h n Testpatient", + "0 4 1 2 3 4 5 6 7 8", + "D4678677", + ], +}; + +export const corpusClinical: Record = { + "eponym-or-surname-like": [ + "Lewy body dementia antipsychotic sensitivity", + "Parkinson's disease psychosis treatment", + "Cotard delusion management", + "Capgras syndrome", + "Huntington's disease depression", + "Wernicke Korsakoff thiamine dose", + "Tourette syndrome tics clonidine", + "Asperger syndrome adult assessment", + "Ganser syndrome", + "Charles Bonnet syndrome", + "Stevens-Johnson syndrome lamotrigine", + "Brugada pattern and antipsychotics", + "Addison's disease depression", + "Cushing syndrome psychosis", + "De Clerambault syndrome", + "Ekbom syndrome", + "Munchausen by proxy", + "Kluver Bucy syndrome", + "Marchiafava Bignami disease", + "Hans Asperger history", + ], + "scale-names": [ + "PANSS scoring", + "HAM-D cut off for severe depression", + "MADRS interpretation", + "Young Mania Rating Scale", + "Edinburgh Postnatal Depression Scale score 13", + "AUDIT-C score", + "CIWA-Ar alcohol withdrawal protocol", + "Abnormal Involuntary Movement Scale", + "Glasgow Coma Scale", + "Montreal Cognitive Assessment cut off", + "Addenbrooke's Cognitive Examination", + "Beck Depression Inventory", + "Hamilton Anxiety Rating Scale", + "Kessler K10 score 30", + "Columbia Suicide Severity Rating Scale", + ], + "guideline-dates": [ + "RANZCP 2016 schizophrenia guideline", + "NICE CG178 2014 psychosis", + "Maudsley 14th edition 2021 clozapine", + "WA Mental Health Act 2014 section 25", + "clozapine protocol updated 01/03/2024", + "TGA rescheduling 2023-07-01 psilocybin", + "Mental Health Act 2014 Form 1A", + "NHMRC 2019 guideline", + "DSM-5-TR 2022 changes", + "ICD-11 2019 complex PTSD", + "Chief Psychiatrist guideline 15/06/2023", + "lithium monitoring guidance revised 2018-05", + ], + "numbers-and-doses": [ + "lithium 0.8 mmol/L target", + "QTc 480 ms on citalopram", + "clozapine level 350 ug/L", + "ANC 1.5 x10^9/L clozapine", + "olanzapine 12.5 mg dose", + "haloperidol 5 mg IM max per 24 hours", + "valproate level 50-100 mg/L", + "QRS over 100 msec TCA overdose", + "paracetamol overdose 150 mg/kg", + "eGFR 45 lithium dose", + "BMI 17.5 anorexia admission criteria", + "potassium 2.9 refeeding", + "lorazepam 2 mg q4h maximum", + "CK 5000 NMS", + "temperature 38.5 clozapine", + ], + ages: [ + "lithium dose for a 70 year old", + "16 yo first episode psychosis", + "ADHD medication in a 7 year old", + "aged 85 delirium haloperidol", + "antidepressant in a 15 year old girl", + "dementia in a 60 year old man", + ], + "ms-and-pt-shorthand": [ + "MS on interferon depression", + "pt with renal impairment lithium", + "pt on clozapine with constipation", + "QTc 480 ms ECG monitoring", + ], + "hospitals-and-places": [ + "Graylands Hospital admission criteria", + "Fiona Stanley Hospital ED mental health", + "Sir Charles Gairdner mental health unit", + "Joondalup Health Campus psychiatry", + "Royal Perth Hospital", + "King Edward Memorial Hospital perinatal", + "Perth Children's Hospital eating disorders", + "Bentley Hospital", + "Rockingham General Hospital", + "Armadale Kelmscott Memorial Hospital", + ], + "services-and-public-numbers": [ + "Mental Health Emergency Response Line 1300 555 788", + "Lifeline 13 11 14", + "Rurallink", + "Kids Helpline 1800 55 1800", + "13YARN", + "Beyond Blue 1300 22 4636", + "Suicide Call Back Service 1300 659 467", + ], + "forms-and-sections": ["Form 1A referral", "Form 4A", "section 21 MHA", "CTO Form 5A", "Form 6A detention"], + "brand-names": [ + "Abilify Maintena dosing", + "Invega Sustenna loading", + "Zyprexa Relprevv post injection syndrome", + "Vyvanse dose", + "Concerta conversion", + ], + "plain-questions": [ + "Clozapine titration schedule", + "Lithium toxicity signs", + "serotonin syndrome versus NMS", + "first line treatment for OCD", + "how to switch from sertraline to venlafaxine", + "antipsychotics in pregnancy", + "ECT consent requirements in WA", + "management of acute agitation", + "treatment resistant depression options", + "bipolar depression first line", + "ketamine for depression", + "opioid withdrawal COWS", + "alcohol withdrawal diazepam loading", + "metabolic monitoring antipsychotics", + "hyperprolactinaemia management", + "tardive dyskinesia valbenazine", + "lamotrigine rash titration", + "SSRIs and bleeding risk", + "benzodiazepine taper schedule", + "delirium screening tools", + ], + "more-eponyms-and-traps": [ + "John Cade lithium discovery", + "St John's wort interactions", + "Alice in Wonderland syndrome", + "Smith-Magenis syndrome behaviour", + "Williams syndrome anxiety", + "Wilson's disease psychosis", + "Graves disease anxiety", + "Hashimoto encephalopathy", + "Creutzfeldt-Jakob disease", + "Pick's disease", + "Prader-Willi syndrome", + "Klinefelter syndrome", + "Turner syndrome", + "Rett syndrome", + "Angelman syndrome", + "Diogenes syndrome", + "Othello syndrome", + "Jerusalem syndrome", + "Down syndrome dementia", + "Fragile X", + "Mary Jane cannabis slang", + "Molly MDMA", + "Charlie cocaine slang", + "Victoria Mental Health Act comparison", + "Sydney guideline clozapine", + ], + "more-numbers": [ + "lithium 12 hours post dose", + "clozapine day 7 fever", + "week 18 ANC monitoring", + "1.5 mg/kg dose", + "Mental Health Act section 2014/25", + "page 123 of the Maudsley", + ], + "people-in-psychiatry": [ + "Kraepelin dementia praecox", + "Bleuler four As", + "Schneider first rank symptoms", + "Kurt Schneider first rank symptoms", + "Aaron Beck cognitive therapy", + "Marsha Linehan DBT", + "Carl Rogers person centred", + "John Bowlby attachment", + ], +}; + +// Held-out set. +export const holdoutIdentifiers: Record = { + "holdout-diverse-bare": [ + "Minh Pham lithium level 1.2", + "Xiao Liu clozapine titration", + "Arjun Patel olanzapine weight", + "Deepika Rao postnatal depression sertraline", + "Chukwuemeka Obi first episode psychosis", + "Amara Diallo ECT consent", + "Omar Haddad agitation droperidol", + "Layla Khoury lithium in pregnancy", + "Tane Ngata clozapine constipation", + "Mere Walker risk assessment", + "Jedda Hill depot overdue", + "Kalinda Mitchell self harm", + "Sanjay Gupta NMS signs", + "Yuki Sato serotonin syndrome", + "Hana Kim anorexia refeeding", + "Jin Park delirium haloperidol", + "Anh Tran quetiapine overdose", + "Thanh Le alcohol withdrawal", + "Elif Yilmaz bipolar depression", + "Nikolai Petrov catatonia lorazepam", + "Lorenzo Bianchi lithium toxicity", + "Sofia Costa clozapine level", + "Ayesha Siddiqui panic disorder", + "Fatuma Abdi PTSD prazosin", + ], + "holdout-surname-first": [ + "Nguyen Thi Lan clozapine", + "Pham Van Duc lithium level", + "Chen Wei olanzapine", + "Wang Fang depression", + ], + "holdout-other-forms": [ + "Kalinda's lithium level is 1.3", + "Prof Hartley lithium", + "Sr Okafor clozapine", + "Minh P. olanzapine", + "Hartley Jane clozapine", + "Sofia (Costa) lithium", + "mary walker lithium", + "Mary Okafor clozapine", + ], +}; + +export const holdoutClinical: Record = { + "holdout-eponyms": [ + "Lesch-Nyhan syndrome self injury", + "Bell's palsy and lithium", + "Brown-Sequard syndrome", + "Guillain-Barre syndrome depression", + "Korsakoff syndrome thiamine", + "Gilles de la Tourette", + "Mary Jane slang cannabis", + "John Cade Bundoora", + "Ken Kesey One Flew Over", + "Sally Anne test theory of mind", + "Lee Silverman voice treatment", + "Hamilton Depression Rating Scale cut off", + "Wilson disease copper psychosis", + "Addenbrooke's ACE-III scoring", + "Edinburgh Postnatal Depression Scale", + ], + "holdout-services": [ + "13YARN 13 92 76", + "Lifeline 13 11 14 crisis", + "MensLine 1300 78 99 78", + "Head to Health 1800 595 212", + "PANDA 1300 726 306", + "Butterfly Foundation 1800 33 4673", + "Alcohol and Drug Support Line 1800 198 024", + ], + "holdout-guideline-dates": [ + "lithium policy reviewed 12/08/2022", + "clozapine guideline version 2.1 dated 03/04/2025", + "ECT protocol effective 01/07/2023", + "MHA code of practice published 1/11/2015", + ], + "holdout-plain": [ + "What Is The Starting Dose Of Lithium", + "Can Clozapine Cause Myocarditis", + "Bipolar Disorder Maintenance Treatment", + "Mental Health Act Form 1A Criteria", + "Is Olanzapine Safe In Pregnancy", + "Does Sertraline Interact With Tramadol", + "Ward Round Checklist Psychiatry", + "First Episode Psychosis Clinic Referral", + "Eating Disorder Admission Criteria", + "Lithium And NSAIDs", + ], +}; diff --git a/tests/patient-detail-corpus.test.ts b/tests/patient-detail-corpus.test.ts new file mode 100644 index 0000000000..4e79a351da --- /dev/null +++ b/tests/patient-detail-corpus.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { clinicalQuestionPatientDetail } from "@/lib/clinical-question-patient-check"; + +import { + corpusClinical, + corpusIdentifiers, + holdoutClinical, + holdoutIdentifiers, +} from "./fixtures/patient-detail-corpus"; + +/** + * Floors for the Clinical question check on the synthetic corpus (privacy audit PD-01 and PD-02, + * 2026-10-26). Before the check was widened it caught 149 of 214 identifiers and held back 7 of + * 158 ordinary questions; on the held-out set it caught 0 of 36 and held back 11 of 36. A change + * that lowers recall or adds a false refusal fails here. + */ +function flagged(set: Record): { caught: string[]; passed: string[] } { + const all = Object.values(set).flat(); + return { + caught: all.filter((text) => clinicalQuestionPatientDetail(text) !== null), + passed: all.filter((text) => clinicalQuestionPatientDetail(text) === null), + }; +} + +describe("patient-detail check on the synthetic corpus", () => { + it("catches at least 187 of the 214 audit identifiers", () => { + const { caught, passed } = flagged(corpusIdentifiers); + expect(caught.length + passed.length).toBe(214); + expect(caught.length).toBeGreaterThanOrEqual(187); + }); + + it("refuses none of the 158 ordinary audit questions", () => { + const { caught, passed } = flagged(corpusClinical); + expect(caught.length + passed.length).toBe(158); + expect(caught).toEqual([]); + }); + + it("catches at least 35 of the 36 held-out names", () => { + const { caught, passed } = flagged(holdoutIdentifiers); + expect(caught.length + passed.length).toBe(36); + expect(caught.length).toBeGreaterThanOrEqual(35); + }); + + it("refuses at most one of the 36 held-out ordinary questions", () => { + const { caught } = flagged(holdoutClinical); + // "Ken Kesey One Flew Over" is the known false refusal: Ken is a listed given name. + expect(caught.length).toBeLessThanOrEqual(1); + }); +}); diff --git a/tests/search-box-patient-detail.dom.test.tsx b/tests/search-box-patient-detail.dom.test.tsx new file mode 100644 index 0000000000..84225715a6 --- /dev/null +++ b/tests/search-box-patient-detail.dom.test.tsx @@ -0,0 +1,188 @@ +// Privacy audit S1 (2026-10-26): every search box checks typed text for patient details before the +// text leaves the page, whether as a request (D26-01, CA-02, CA-05) or in a page address (CA-01, +// CA-03, CA-05). Every name and number here is invented. + +import { readFileSync } from "node:fs"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { executeMasterSearchHeaderModeAction } from "@/components/clinical-dashboard/master-search-header-actions"; +import { preloadSearchPatientCheck } from "@/components/clinical-dashboard/search-patient-detail-guard"; +import { useDifferentialSearch } from "@/components/clinical-dashboard/use-differential-catalog"; +import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; + +const authSession = vi.hoisted(() => ({ + authorizationHeader: { Authorization: "Bearer user-a-token" }, + markSessionExpired: vi.fn(), + session: { user: { id: "user-a" } }, + status: "authenticated" as const, +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => authSession, +})); + +let fetchMock: ReturnType>; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +async function settle() { + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + for (let step = 0; step < 6; step += 1) await Promise.resolve(); + }); +} + +beforeAll(async () => { + await preloadSearchPatientCheck(); +}); + +beforeEach(() => { + vi.useFakeTimers(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const HELD_BACK = /^This search looks like it holds a patient detail/; + +describe("Medication search (D26-01, CA-02, CA-05)", () => { + it("never sends text that looks like a patient detail, and says why", async () => { + const { result } = renderHook(() => useMedicationCatalog("Mr John Testpatient lithium")); + await settle(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toMatch(HELD_BACK); + expect(result.current.error).not.toContain("Testpatient"); + }); + + it("still sends an ordinary medicine search", async () => { + fetchMock.mockResolvedValue(jsonResponse({ records: [], total: 0 })); + renderHook(() => useMedicationCatalog("clozapine")); + await settle(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]![0])).toContain("q=clozapine"); + }); +}); + +describe("Differentials search (CA-02)", () => { + it("never sends text that looks like a patient detail, and says why with no Retry", async () => { + const { result } = renderHook(() => useDifferentialSearch("Sarah Jones psychosis")); + await settle(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current.status).toBe("error"); + expect(result.current.heldBack).toMatch(HELD_BACK); + }); + + it("still sends an ordinary differentials search", async () => { + fetchMock.mockImplementation(async () => jsonResponse({ matches: [] })); + const { result } = renderHook(() => useDifferentialSearch("psychosis")); + await settle(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result.current.heldBack).toBeUndefined(); + }); +}); + +describe("Dictionary menu search (CA-03)", () => { + function runDictionarySearch(trimmedQuery: string): string[] { + const pushed: string[] = []; + executeMasterSearchHeaderModeAction({ + actionId: "dictionary-search", + trimmedQuery, + onQueryModeChange: () => {}, + onQueryChange: () => {}, + onSearchModeChange: () => {}, + openScopePicker: () => {}, + pushIfLeaveConfirmed: (href) => pushed.push(href), + factsheetsTopicsHref: "/factsheets/topics", + canAccessFavourites: true, + }); + return pushed; + } + + it("leaves a patient detail out of the address", () => { + expect(runDictionarySearch("Minh Pham lithium")).toEqual(["/dictionary/search"]); + }); + + it("carries an ordinary term", () => { + expect(runDictionarySearch("anhedonia")).toEqual(["/dictionary/search?q=anhedonia"]); + }); +}); + +/** + * The page-level boxes are large client pages, so these read their source for the guard sitting in + * front of each address write, the way tests/search-route-ownership.test.ts does. + */ +function source(path: string): string { + return readFileSync(path, "utf8"); +} + +describe("page search boxes route through the check before writing ?q= (CA-01, CA-03, CA-05)", () => { + it.each([ + [ + "Tools results box", + "src/components/tools/tools-search-results-page.tsx", + /const refusal = searchTextPatientDetailRefusal\(submittedQuery\);[\s\S]{0,200}?return;[\s\S]{0,40}?\}\s*router\.push\(`\/tools\?q=/, + ], + [ + "Tools launcher box", + "src/components/applications-launcher-page.tsx", + /const refusal = searchTextPatientDetailRefusal\(submittedQuery\);[\s\S]{0,200}?return;[\s\S]{0,40}?\}\s*router\.push\(`\/tools\?q=/, + ], + [ + "Calculators box", + "src/components/calculators/search-page.tsx", + /query\.trim\(\) && !searchTextPatientDetailRefusal\(query\)\) \{\s*nextParams\.set\("q"/, + ], + [ + "On Call Refer services link", + "src/components/on-call/refer/refer-page.tsx", + /query\.trim\(\) && !searchTextPatientDetailRefusal\(query\)\s*\?\s*`\/services\/search\?q=/, + ], + [ + "Dictionary topic box (each keystroke)", + "src/components/dictionary/dictionary-catalogue-pages.tsx", + /writeCheckedSearchText\(\s*event\.target\.value,\s*\(value\) => setOne\("q", value\)/, + ], + [ + "Medication suggested, recent and cross-mode searches", + "src/components/clinical-dashboard/use-dashboard-search-execution.ts", + /function setMedicationSearchQuery\([^)]*\) \{[\s\S]{0,400}?const refusal = searchTextPatientDetailRefusal\(trimmedSearchText\);[\s\S]{0,120}?return;/, + ], + [ + "Medication, Services, Forms and Favourites chips", + "src/components/ClinicalDashboard.tsx", + /void checkSearchTextPatientDetail\(crossQuery\)\.then\(\(refusal\) => \{[\s\S]{0,200}?if \(refusal\) \{[\s\S]{0,120}?return;[\s\S]{0,40}?\}\s*latestNavigateCrossModeSearch\(mode, crossQuery\);/, + ], + [ + "Shared deep links on every search page", + "src/components/clinical-dashboard/global-search-shell.tsx", + /void checkSearchTextPatientDetail\(requestedQuery\)\.then\(\(refusal\) => \{[\s\S]{0,400}?url\.searchParams\.delete\("q"\);[\s\S]{0,300}?router\.replace\(/, + ], + ])("%s", (_label, path, guarded) => { + expect(source(path)).toMatch(guarded); + }); +}); + +describe("note fields keep text from autofill, spelling and grammar extensions (CA-08)", () => { + it.each([ + ["Safety plan entries", "src/components/patient-safety-plan.tsx", 2], + ["My Day quick note", "src/components/my-day/my-day-work-me-cards.tsx", 1], + ])("%s", (_label, path, fields) => { + const matches = source(path).match( + /autoComplete="off"\s+spellCheck=\{false\}\s+data-gramm="false"\s+data-enable-grammarly="false"/g, + ); + expect(matches?.length ?? 0).toBeGreaterThanOrEqual(fields); + }); +}); diff --git a/tests/search-patient-detail-guard.test.ts b/tests/search-patient-detail-guard.test.ts index 331e840f9a..b728ab93f5 100644 --- a/tests/search-patient-detail-guard.test.ts +++ b/tests/search-patient-detail-guard.test.ts @@ -1,21 +1,24 @@ // Design audit D-03: typed search text is checked for patient details before navigation writes it -// into a page address (`?q=`). The full check loads lazily (bundle budget); until it has loaded, -// the identifier-shape check still holds back record numbers, dates of birth, phones and emails. +// into a page address (`?q=`). The full check loads lazily (bundle budget). Until it has loaded the +// guard fails closed (privacy audit CA-04, 2026-10-26): identifier shapes get their own sentence and +// anything else is held back as still being checked, so a name never slips through in that window. import { describe, expect, it, vi } from "vitest"; describe("searchTextPatientDetailRefusal", () => { - it("holds back identifier shapes before the full check has loaded, and passes ordinary searches", async () => { + it("fails closed before the full check has loaded", async () => { vi.resetModules(); const guard = await import("@/components/clinical-dashboard/search-patient-detail-guard"); expect(guard.searchTextPatientDetailRefusal("schizophrenia criteria MRN: 1234567")).toBe( guard.SEARCH_IDENTIFIER_HELD_BACK, ); - expect(guard.searchTextPatientDetailRefusal("schizophrenia criteria")).toBeNull(); expect(guard.searchTextPatientDetailRefusal(" ")).toBeNull(); - // A name alone needs the full check, which has not loaded yet. - expect(guard.searchTextPatientDetailRefusal("Mrs Jane Brown schizophrenia criteria")).toBeNull(); + // A name needs the full check; until it loads, nothing typed is let through. + expect(guard.searchTextPatientDetailRefusal("Mrs Jane Brown schizophrenia criteria")).toBe( + guard.SEARCH_CHECK_LOADING, + ); + expect(guard.searchTextPatientDetailRefusal("schizophrenia criteria")).toBe(guard.SEARCH_CHECK_LOADING); }); it("uses the full patient-detail check once it has loaded", async () => { @@ -26,7 +29,67 @@ describe("searchTextPatientDetailRefusal", () => { expect(guard.searchTextPatientDetailRefusal("Mrs Jane Brown schizophrenia criteria")).toMatch( /^This search looks like it holds a patient detail/, ); + expect(guard.searchTextPatientDetailRefusal("Minh Pham lithium level")).toMatch( + /^This search looks like it holds a patient detail/, + ); expect(guard.searchTextPatientDetailRefusal("lithium dose for a 70 year old")).toBeNull(); expect(guard.searchTextPatientDetailRefusal("schizophrenia criteria")).toBeNull(); }); }); + +describe("checkSearchTextPatientDetail", () => { + it("waits for the full check, then reads the text with it", async () => { + vi.resetModules(); + const guard = await import("@/components/clinical-dashboard/search-patient-detail-guard"); + + await expect(guard.checkSearchTextPatientDetail("Mr John Testpatient lithium")).resolves.toMatch( + /^This search looks like it holds a patient detail/, + ); + await expect(guard.checkSearchTextPatientDetail("clozapine titration")).resolves.toBeNull(); + await expect(guard.checkSearchTextPatientDetail("")).resolves.toBeNull(); + }); + + it("holds the search back when the full check cannot load", async () => { + vi.resetModules(); + vi.doMock("@/lib/clinical-question-patient-check", () => { + throw new Error("chunk failed to load"); + }); + try { + const guard = await import("@/components/clinical-dashboard/search-patient-detail-guard"); + await expect(guard.checkSearchTextPatientDetail("clozapine titration")).resolves.toBe( + guard.SEARCH_CHECK_UNAVAILABLE, + ); + await expect(guard.checkSearchTextPatientDetail("lithium DOB: 01/02/1990")).resolves.toBe( + guard.SEARCH_IDENTIFIER_HELD_BACK, + ); + } finally { + vi.doUnmock("@/lib/clinical-question-patient-check"); + } + }); +}); + +describe("writeCheckedSearchText", () => { + it("writes text that passes and clears the address copy with a warning for text that does not", async () => { + vi.resetModules(); + const guard = await import("@/components/clinical-dashboard/search-patient-detail-guard"); + const writes: string[] = []; + const warnings: string[] = []; + + guard.writeCheckedSearchText( + "lithium", + (value) => writes.push(value), + (message) => warnings.push(message), + ); + await vi.waitFor(() => expect(writes).toEqual(["lithium"])); + expect(warnings).toEqual([]); + + guard.writeCheckedSearchText( + "Sarah Jones lithium", + (value) => writes.push(value), + (message) => warnings.push(message), + ); + await vi.waitFor(() => expect(writes).toEqual(["lithium", ""])); + expect(warnings).toHaveLength(1); + expect(warnings[0]).not.toContain("Jones"); + }); +}); diff --git a/tests/security-headers.test.ts b/tests/security-headers.test.ts index 6d7e1d50a2..596e92ce0b 100644 --- a/tests/security-headers.test.ts +++ b/tests/security-headers.test.ts @@ -84,6 +84,8 @@ describe("security headers", () => { it("keeps the baseline hardening headers", () => { expect(byKey.get("X-Content-Type-Options")).toBe("nosniff"); expect(byKey.get("X-Frame-Options")).toBe("DENY"); + // Origin only, even within the site, so ?q= search text never rides in a Referer (privacy audit CA-06). + expect(byKey.get("Referrer-Policy")).toBe("strict-origin"); expect(byKey.get("Cross-Origin-Opener-Policy")).toBe("same-origin"); }); From c9fda60e85380667fcbf86b8de66ad34d95eb8c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:47:54 +0000 Subject: [PATCH 3/6] test: load the patient-detail check before search hook tests The search guard now fails closed until the full check has loaded, so tests that expect a search to go out load it first. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EyULT41weiAuu3CroGX7oD --- tests/instant-results.dom.test.tsx | 9 ++++++++- tests/medication-catalog-errors.dom.test.tsx | 9 ++++++++- tests/therapy-submit-query-patient-guard.dom.test.tsx | 9 ++++++++- tests/use-differential-search.dom.test.tsx | 9 ++++++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/tests/instant-results.dom.test.tsx b/tests/instant-results.dom.test.tsx index e575ae59b9..2891ef9058 100644 --- a/tests/instant-results.dom.test.tsx +++ b/tests/instant-results.dom.test.tsx @@ -1,8 +1,9 @@ /** @vitest-environment jsdom */ import { act, render, renderHook, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { preloadSearchPatientCheck } from "@/components/clinical-dashboard/search-patient-detail-guard"; import { ResultRowsPlaceholder } from "@/components/clinical-dashboard/result-rows-placeholder"; import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { @@ -34,6 +35,12 @@ function jsonResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); } +// Searches check typed text for patient details before they send or navigate; load the check +// once up front so the steps below see the request go out (privacy audit S1, 2026-10-26). +beforeAll(async () => { + await preloadSearchPatientCheck(); +}); + beforeEach(() => { fetchMock = vi.fn(() => Promise.resolve(jsonResponse({ records: [], total: 0 }))); vi.stubGlobal("fetch", fetchMock); diff --git a/tests/medication-catalog-errors.dom.test.tsx b/tests/medication-catalog-errors.dom.test.tsx index e9def578f9..75a131439e 100644 --- a/tests/medication-catalog-errors.dom.test.tsx +++ b/tests/medication-catalog-errors.dom.test.tsx @@ -3,8 +3,9 @@ // Design audit D-14: a failed or stalled Medication search said "Request failed (503)" or // stayed loading for ever. It now says what happened in plain words and gives up after a deadline. import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { preloadSearchPatientCheck } from "@/components/clinical-dashboard/search-patient-detail-guard"; import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; vi.mock("@/lib/supabase/client", () => ({ @@ -13,6 +14,12 @@ vi.mock("@/lib/supabase/client", () => ({ let fetchMock: ReturnType>; +// Searches check typed text for patient details before they send or navigate; load the check +// once up front so the steps below see the request go out (privacy audit S1, 2026-10-26). +beforeAll(async () => { + await preloadSearchPatientCheck(); +}); + beforeEach(() => { vi.useFakeTimers(); fetchMock = vi.fn(); diff --git a/tests/therapy-submit-query-patient-guard.dom.test.tsx b/tests/therapy-submit-query-patient-guard.dom.test.tsx index 744644c0ef..c58701d737 100644 --- a/tests/therapy-submit-query-patient-guard.dom.test.tsx +++ b/tests/therapy-submit-query-patient-guard.dom.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { preloadSearchPatientCheck } from "@/components/clinical-dashboard/search-patient-detail-guard"; import { TcProvider, useTcBindings } from "@/components/therapy-compass/bindings"; // Design audit D-03: a Therapies search that looks like it holds a patient detail is not @@ -46,6 +47,12 @@ function renderProbe(text: string) { fireEvent.click(screen.getByRole("button", { name: "Submit" })); } +// Searches check typed text for patient details before they send or navigate; load the check +// once up front so the steps below see the request go out (privacy audit S1, 2026-10-26). +beforeAll(async () => { + await preloadSearchPatientCheck(); +}); + describe("Therapies submitQuery patient-detail check", () => { it("keeps a search holding a record number out of the address", () => { renderProbe("CBT for psychosis MRN: 1234567"); diff --git a/tests/use-differential-search.dom.test.tsx b/tests/use-differential-search.dom.test.tsx index 6f096c909d..a173823a91 100644 --- a/tests/use-differential-search.dom.test.tsx +++ b/tests/use-differential-search.dom.test.tsx @@ -1,6 +1,7 @@ import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { preloadSearchPatientCheck } from "@/components/clinical-dashboard/search-patient-detail-guard"; import { clearDifferentialSearchCacheForTests, useDifferentialSearch, @@ -19,6 +20,12 @@ vi.mock("@/lib/supabase/client", () => ({ let fetchMock: ReturnType>; +// Searches check typed text for patient details before they send or navigate; load the check +// once up front so the steps below see the request go out (privacy audit S1, 2026-10-26). +beforeAll(async () => { + await preloadSearchPatientCheck(); +}); + beforeEach(() => { vi.useFakeTimers(); clearDifferentialSearchCacheForTests(); From 16b84cfd7e0d3c8dd0f342e14d2f1cfd195e4503 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:49:35 +0000 Subject: [PATCH 4/6] fix(privacy): load the patient-detail check when a search box mounts The guard fails closed until the check has loaded, so loading it on mount keeps a quick Enter from being held back as unchecked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EyULT41weiAuu3CroGX7oD --- .../clinical-dashboard/search-patient-detail-guard.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/clinical-dashboard/search-patient-detail-guard.ts b/src/components/clinical-dashboard/search-patient-detail-guard.ts index c9f2e1a491..03008543dc 100644 --- a/src/components/clinical-dashboard/search-patient-detail-guard.ts +++ b/src/components/clinical-dashboard/search-patient-detail-guard.ts @@ -35,11 +35,15 @@ export function preloadSearchPatientCheck(): Promise { return pendingPatientCheck; } -/** Loads the full check once there is search text, so a later submit or mode switch can use it. */ +/** + * Loads the full check when a search box mounts, and again once there is text if that first + * download failed, so a quick submit or mode switch finds it ready. The guard fails closed until + * it has loaded (CA-04), so loading it early keeps a fast Enter from being held back as unchecked. + */ export function usePreloadSearchPatientCheck(text: string) { const hasText = Boolean(text.trim()); useEffect(() => { - if (hasText) void preloadSearchPatientCheck(); + void preloadSearchPatientCheck(); }, [hasText]); } From 173f9da613d5b974d83d02b793f75c72a5f09a3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:25:36 +0000 Subject: [PATCH 5/6] Privacy: check the in-document search for patient details before it is sent (CA-02) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EyULT41weiAuu3CroGX7oD --- src/components/DocumentViewer.tsx | 18 ++++++++++++++---- tests/search-box-patient-detail.dom.test.tsx | 5 +++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index b7eae1b7bb..afe96eb327 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1,5 +1,9 @@ "use client"; +import { + checkSearchTextPatientDetail, + SearchHeldBack, +} from "@/components/clinical-dashboard/search-patient-detail-guard"; import { useRouter } from "next/navigation"; import { ArrowLeft, @@ -827,10 +831,16 @@ export function DocumentViewer({ const timeout = window.setTimeout(() => { setSearchingDocument(true); setDocumentSearchError(null); - fetch(`/api/documents/${documentId}/search?q=${encodeURIComponent(query)}&limit=30`, { - signal: controller.signal, - headers: clientDemoMode ? undefined : authorizationHeader, - }) + // Typed text is checked for patient details before it leaves the page (privacy audit CA-02). + // A held-back search shows the held-back sentence through the error line below. + checkSearchTextPatientDetail(query) + .then((refusal) => { + if (refusal) throw new SearchHeldBack(refusal); + return fetch(`/api/documents/${documentId}/search?q=${encodeURIComponent(query)}&limit=30`, { + signal: controller.signal, + headers: clientDemoMode ? undefined : authorizationHeader, + }); + }) .then(async (response) => { if (response.status === 401) markSessionExpired(); if (!response.ok) throw await parseApiErrorResponse(response); diff --git a/tests/search-box-patient-detail.dom.test.tsx b/tests/search-box-patient-detail.dom.test.tsx index 84225715a6..c165142ab1 100644 --- a/tests/search-box-patient-detail.dom.test.tsx +++ b/tests/search-box-patient-detail.dom.test.tsx @@ -165,6 +165,11 @@ describe("page search boxes route through the check before writing ?q= (CA-01, C "src/components/ClinicalDashboard.tsx", /void checkSearchTextPatientDetail\(crossQuery\)\.then\(\(refusal\) => \{[\s\S]{0,200}?if \(refusal\) \{[\s\S]{0,120}?return;[\s\S]{0,40}?\}\s*latestNavigateCrossModeSearch\(mode, crossQuery\);/, ], + [ + "In-document search (CA-02)", + "src/components/DocumentViewer.tsx", + /checkSearchTextPatientDetail\(query\)\s*\.then\(\(refusal\) => \{\s*if \(refusal\) throw new SearchHeldBack\(refusal\);\s*return fetch\(`\/api\/documents\/\$\{documentId\}\/search\?q=/, + ], [ "Shared deep links on every search page", "src/components/clinical-dashboard/global-search-shell.tsx", From da18ffd0a2572c9585f4a3bc88806081f6f9f129 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:38:03 +0000 Subject: [PATCH 6/6] Privacy: keep labelled record numbers, emails and visit dates refused (review on #3834) Service-number digits are replaced by a word instead of removed, so the same digits as an MRN or inside an email are still caught. Document dates now need a document word, and a patient word between blocks the exemption. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EyULT41weiAuu3CroGX7oD --- src/lib/clinical-question-patient-check.ts | 18 +++++++++++------- tests/clinical-question-patient-check.test.ts | 8 ++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/lib/clinical-question-patient-check.ts b/src/lib/clinical-question-patient-check.ts index 5b1fa75f52..b4cd8acb12 100644 --- a/src/lib/clinical-question-patient-check.ts +++ b/src/lib/clinical-question-patient-check.ts @@ -71,26 +71,30 @@ function withoutClinicalShorthand(text: string): string { /** * Public service numbers ("Lifeline 13 11 14", "MHERL 1300 555 788", "Kids Helpline 1800 55 1800"). * A 1300 or 1800 number belongs to a service, never to a person, so it is set aside before the - * phone check (privacy audit PD-02, 2026-10-26). A mobile or landline is still refused. + * phone check (privacy audit PD-02, 2026-10-26). A mobile or landline is still refused. It is + * replaced by a word rather than removed, so the same digits typed as a labelled record number + * ("MRN: 1300555788") or inside an email address are still caught by those checks. */ const PUBLIC_SERVICE_NUMBER = /(? `document${between}`); } diff --git a/tests/clinical-question-patient-check.test.ts b/tests/clinical-question-patient-check.test.ts index f455a59fe4..641b475a06 100644 --- a/tests/clinical-question-patient-check.test.ts +++ b/tests/clinical-question-patient-check.test.ts @@ -317,6 +317,14 @@ describe("clinicalQuestionPatientDetail: public numbers and document dates (PD-0 "guideline born 01/02/1990", "DOB: 01/02/1990 lithium", "seen 01/02/2024 lithium level", + // Review findings on PR #3834: service-number digits as a labelled record number or an email, + // and a visit verb with no document word. + "lithium MRN: 1300555788", + "1300555788@example.com clozapine", + "patient reviewed 01/02/1990", + "reviewed patient 01/02/1990 lithium", + "updated 01/02/1990 lithium", + "guideline reviewed patient 01/02/1990", ])("still refuses %s", (question) => { expect(clinicalQuestionPatientDetail(question), question).not.toBeNull(); });