fix(02-12): reject divergent shared-record state

- Verify the active record matches its canonical switcher entry\n- Skip the owner OCR probe in delegated Labs records
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 13:39:37 +02:00
1 parent 358bb39465
commit b844d04676
6 files changed
+129 -3

No files matched your search

+12
View File
@@ -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);
});
+13 -2
View File
@@ -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" }),
@@ -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,
);
});
});
+17
View File
@@ -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<OcrCapabilityDto>({
@@ -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" }],
+33 -1
View File
@@ -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<typeof accountAccessEntrySchema>,
canonical: z.infer<typeof accountAccessEntrySchema>,
): 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 };
}