mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
358bb39465
commit
b844d04676
6 files changed
+129
-3
No files matched your search
@@ -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
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user