diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index 8066eddf5..016bb3f76 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -64,6 +64,15 @@ test.describe.serial("scoped sharing browser journeys", () => { ); await expect(entry).toHaveCount(1); + const unexpectedOcrCapability = + record.domain === "labs" + ? page.waitForRequest( + (request) => + request.method() === "GET" && + new URL(request.url()).pathname === "/api/labs/ocr/capability", + { timeout: 500 }, + ) + : null; const read = page.waitForResponse( (response) => response.request().method() === "GET" && @@ -82,6 +91,9 @@ test.describe.serial("scoped sharing browser journeys", () => { page.locator('[data-slot="shared-record-unavailable"]'), ).toHaveCount(0); await read; + if (unexpectedOcrCapability) { + await expect(unexpectedOcrCapability).rejects.toThrow(/Timeout/); + } await leaveRecord(page); }); diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 381bb1723..238e26f80 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -17,7 +17,10 @@ import { import { LabForm } from "@/components/labs/lab-form"; import { LabList } from "@/components/labs/lab-list"; import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog"; -import { useOcrCapability } from "@/components/labs/use-ocr-extract"; +import { + shouldProbeOcrCapability, + useOcrCapability, +} from "@/components/labs/use-ocr-extract"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -53,7 +56,6 @@ export default function LabsPage() { // v1.18.9 — Lab-OCR "Scan a report" dialog. The scan affordance only shows // when the user's configured AI provider can read images (capability probe). const [scanOpen, setScanOpen] = useState(false); - const ocrCapability = useOcrCapability(isAuthenticated); // Gated on the resolved `modules.labs` flag from `GET /api/auth/me` (the // per-user toggle AND the operator server-wide kill-switch). Default-on: an @@ -61,6 +63,15 @@ export default function LabsPage() { // explicit `false`. Every `/api/labs/*` route also enforces the gate // server-side, so this is a UX redirect, not the security boundary. const enabled = inSharedRecord || user?.modules?.labs !== false; + const ocrCapability = useOcrCapability( + shouldProbeOcrCapability({ + isAuthenticated, + isLoading, + labsEnabled: enabled, + mounted, + canManage, + }), + ); const refreshVisible = useCallback( () => queryClient.invalidateQueries({ type: "active" }), diff --git a/src/components/labs/__tests__/ocr-capability-gate.test.ts b/src/components/labs/__tests__/ocr-capability-gate.test.ts new file mode 100644 index 000000000..4e3be2b55 --- /dev/null +++ b/src/components/labs/__tests__/ocr-capability-gate.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { shouldProbeOcrCapability } from "../use-ocr-extract"; + +describe("shouldProbeOcrCapability", () => { + it("runs the owner-only capability probe only when the scan control can render", () => { + const ownerControl = { + isAuthenticated: true, + isLoading: false, + labsEnabled: true, + mounted: true, + canManage: true, + }; + + expect(shouldProbeOcrCapability(ownerControl)).toBe(true); + expect( + shouldProbeOcrCapability({ ...ownerControl, isAuthenticated: false }), + ).toBe(false); + expect( + shouldProbeOcrCapability({ ...ownerControl, canManage: false }), + ).toBe(false); + expect( + shouldProbeOcrCapability({ ...ownerControl, labsEnabled: false }), + ).toBe(false); + expect(shouldProbeOcrCapability({ ...ownerControl, mounted: false })).toBe( + false, + ); + }); +}); diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts index 8f05ebcb1..7f2bd30c7 100644 --- a/src/components/labs/use-ocr-extract.ts +++ b/src/components/labs/use-ocr-extract.ts @@ -40,6 +40,23 @@ export interface OcrCommitResult extends OcrCommitResponseDto { inserted: LabResultDto[]; } +/** The capability route resolves the caller's own provider configuration. */ +export function shouldProbeOcrCapability({ + isAuthenticated, + isLoading, + labsEnabled, + mounted, + canManage, +}: { + isAuthenticated: boolean; + isLoading: boolean; + labsEnabled: boolean; + mounted: boolean; + canManage: boolean; +}): boolean { + return isAuthenticated && !isLoading && labsEnabled && mounted && canManage; +} + /** Capability probe — refetched when the scan dialog opens. */ export function useOcrCapability(enabled: boolean) { return useQuery({ diff --git a/src/lib/sharing/__tests__/account-access-schema.test.ts b/src/lib/sharing/__tests__/account-access-schema.test.ts index b508839e7..69db679b9 100644 --- a/src/lib/sharing/__tests__/account-access-schema.test.ts +++ b/src/lib/sharing/__tests__/account-access-schema.test.ts @@ -58,6 +58,31 @@ describe("parseAccountAccess", () => { }); }); + it.each([ + [ + "legacy access and resolved level", + { access: "write", level: "write", canWrite: true }, + {}, + ], + ["record kind", { recordKind: "managed" }, { recordKind: "managed" }], + ["scope", { sections: ["measurements"] }, {}], + [ + "resolved write capability", + { access: "write", level: "write", canWrite: true }, + {}, + ], + ["username", { username: "different-record" }, {}], + ["display name", { displayName: "Different record" }, {}], + ])( + "fails closed when active disagrees with its canonical entry on %s", + (_field, activeOverrides, blockOverrides) => { + const active = { ...sharedEntry, ...activeOverrides }; + const block = accessBlock({ active, ...blockOverrides }); + + expect(parseAccountAccess(block)).toBeNull(); + }, + ); + it.each([ ["unknown level", { level: "owner" }], ["unknown record kind", { recordKind: "other" }], diff --git a/src/lib/sharing/account-access-schema.ts b/src/lib/sharing/account-access-schema.ts index 2d87bc1f0..72faf24e2 100644 --- a/src/lib/sharing/account-access-schema.ts +++ b/src/lib/sharing/account-access-schema.ts @@ -61,6 +61,33 @@ export const accountAccessBlockSchema = z.object({ canSwitch: z.boolean(), }); +/** Both published views of the active record must name the same resolved grant. */ +function matchesCanonicalAccountAccessEntry( + active: z.infer, + canonical: z.infer, +): boolean { + const activeSections = active.sections; + const canonicalSections = canonical.sections; + const sameSections = + activeSections === null || canonicalSections === null + ? activeSections === canonicalSections + : activeSections.length === canonicalSections.length && + activeSections.every( + (section, index) => section === canonicalSections[index], + ); + + return ( + active.accountId === canonical.accountId && + active.username === canonical.username && + active.displayName === canonical.displayName && + active.access === canonical.access && + active.level === canonical.level && + active.recordKind === canonical.recordKind && + active.canWrite === canonical.canWrite && + sameSections + ); +} + /** Parse the server-resolved access block as a closed presentation contract. */ export function parseAccountAccess(value: unknown): AccountAccess | null { const parsed = accountAccessBlockSchema.safeParse(value); @@ -88,7 +115,12 @@ export function parseAccountAccess(value: unknown): AccountAccess | null { const canonicalActive = accounts.find( (entry) => entry.accountId === active.accountId, ); - if (!canonicalActive) return null; + if ( + !canonicalActive || + !matchesCanonicalAccountAccessEntry(active, canonicalActive) + ) { + return null; + } return { accounts, active: canonicalActive, recordKind, canSwitch }; }