mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(02-12.1): give an unprovable record context a way out
A selector pointed at a record whose grant has lapsed reports the same disagreement on every /api/auth/me, so routing it to the hydration gate rendered a permanent spinner with no controls. Expiry reaches this state on its own: revocation clears the selector inside its transaction and expiry does not. - treat an unprovable context as a refusal, which paints the door with the button back to one's own record - check the refusal ahead of the hydration gate, so neither trigger can render behind a spinner again
This commit is contained in:
1 parent
ffe38c9bb6
commit
0fee005683
4 files changed
+111
-21
No files matched your search
@@ -39,6 +39,8 @@
|
||||
* "removed".
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -290,3 +292,48 @@ describe("<AuthShell> — the owner-only doors", () => {
|
||||
expect(html).not.toContain("sentinel-unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — a refusal must never render behind the hydration gate.
|
||||
*
|
||||
* The two branches look interchangeable and are not. `RecordScopeHydrationGate`
|
||||
* is a bare spinner with no controls, which is correct for a transition that
|
||||
* ends on its own. A refusal does not end on its own — `/api/auth/me` reports
|
||||
* the same disagreement on every boot — so behind the gate it renders as a
|
||||
* permanent "Loading…" with no way out, which is what an audit found on the
|
||||
* grant-expiry path.
|
||||
*
|
||||
* Asserted on source order rather than by rendering, because the shell's
|
||||
* branches are early returns: whichever `if` comes first wins, and that is
|
||||
* exactly the fact worth freezing. Break it by moving the `accessRefused`
|
||||
* block back below the gate.
|
||||
*/
|
||||
describe("the refusal door is reachable", () => {
|
||||
const SHELL = readFileSync(
|
||||
join(process.cwd(), "src/components/layout/auth-shell.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("checks accessRefused before the hydration gate", () => {
|
||||
const refusal = SHELL.indexOf("if (accessRefused) {");
|
||||
const gate = SHELL.indexOf("<RecordScopeHydrationGate");
|
||||
// Non-zero proof: a renamed branch must fail here rather than agree with
|
||||
// two -1s.
|
||||
expect(refusal).toBeGreaterThan(-1);
|
||||
expect(gate).toBeGreaterThan(-1);
|
||||
expect(refusal).toBeLessThan(gate);
|
||||
});
|
||||
|
||||
it("gives the refusal door an actual way out", () => {
|
||||
// A door with no handle is the same wedge wearing different paint.
|
||||
const door = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/layout/shared-record-unavailable.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(door).toContain("shared-record-unavailable-leave");
|
||||
expect(door).toMatch(/switchAccount\.mutate\(null\)/);
|
||||
});
|
||||
});
|
||||
@@ -234,6 +234,36 @@ export function AuthShell({
|
||||
);
|
||||
}
|
||||
|
||||
// A refused record context comes FIRST, ahead of the hydration gate.
|
||||
//
|
||||
// Both used to be checked in the other order, and it mattered: the gate is a
|
||||
// bare spinner with no controls, which is right for a transition that ends on
|
||||
// its own and wrong for a state that does not. A refusal — a malformed access
|
||||
// block, or a selector pointed at a record whose grant has lapsed — reports
|
||||
// the same thing on every `/api/auth/me`, so behind the gate it renders as a
|
||||
// permanent "Loading…" with no way out. Ahead of it, the same state renders
|
||||
// the refusal door, which carries the button back to one's own record.
|
||||
//
|
||||
// Safe against the loading race by construction: every input to
|
||||
// `accessRefused` comes from a resolved account payload, so it cannot be true
|
||||
// while `isLoading` is.
|
||||
// A present but malformed access block is not the older-server case. The
|
||||
// server may still be switched, so mounting children here could fetch target
|
||||
// data under owner controls. Offer only the actor-scoped way out.
|
||||
if (accessRefused) {
|
||||
return (
|
||||
<div
|
||||
data-slot="invalid-record-access-refusal"
|
||||
className="flex min-h-dvh flex-col"
|
||||
>
|
||||
<MaintainershipBanner />
|
||||
<main className="mx-auto w-full max-w-screen-xl px-4 py-10 md:px-6">
|
||||
<SharedRecordUnavailable />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Every protected route waits for `/api/auth/me`, not only Settings and
|
||||
// Admin. The payload resolves the active record as well as identity, so a
|
||||
// child mounted before it could issue an actor-scoped read before the shell
|
||||
@@ -257,23 +287,6 @@ export function AuthShell({
|
||||
);
|
||||
}
|
||||
|
||||
// A present but malformed access block is not the older-server case. The
|
||||
// server may still be switched, so mounting children here could fetch target
|
||||
// data under owner controls. Offer only the actor-scoped way out.
|
||||
if (accessRefused) {
|
||||
return (
|
||||
<div
|
||||
data-slot="invalid-record-access-refusal"
|
||||
className="flex min-h-dvh flex-col"
|
||||
>
|
||||
<MaintainershipBanner />
|
||||
<main className="mx-auto w-full max-w-screen-xl px-4 py-10 md:px-6">
|
||||
<SharedRecordUnavailable />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Onboarding page — minimal shell, no sidebar/nav
|
||||
if (isOnboardingPage) {
|
||||
return (
|
||||
|
||||
@@ -179,7 +179,30 @@ describe("an unprovable record context withholds every control", () => {
|
||||
const held = resolveRecordCapabilities(null, false, false, true);
|
||||
expect(held.canAdd).toBe(false);
|
||||
expect(held.canManage).toBe(false);
|
||||
expect(held.recordSessionPending).toBe(true);
|
||||
// REFUSED, not pending. `recordSessionPending` renders a bare spinner with
|
||||
// no controls, which is right for a switch in flight and a wedge here: this
|
||||
// state reports the same thing on every `/api/auth/me`, so it never ends on
|
||||
// its own. `accessRefused` renders the door with the way back out.
|
||||
expect(held.accessRefused).toBe(true);
|
||||
expect(held.recordSessionPending).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes an EXPIRED grant to the refusal door, not the spinner", () => {
|
||||
// The second trigger, and the one that arrives without anybody doing
|
||||
// anything: revocation clears the selector inside its transaction, expiry
|
||||
// does NOT. So the session stays pointed at a record whose grant has
|
||||
// lapsed, `/api/auth/me` resolves `active: null` because the entry no
|
||||
// longer survives the live-grant pass, and `recordSession.scope` still
|
||||
// names the owner. Every delegable read 403s from here.
|
||||
const expired = recordContextIsUnproven(
|
||||
{ epoch: 3, scope: "acct-owner" },
|
||||
null,
|
||||
);
|
||||
expect(expired).toBe(true);
|
||||
const capabilities = resolveRecordCapabilities(null, false, false, expired);
|
||||
expect(capabilities.accessRefused).toBe(true);
|
||||
expect(capabilities.recordSessionPending).toBeUndefined();
|
||||
expect(capabilities.inSharedRecord).toBe(true);
|
||||
});
|
||||
|
||||
it("holds when the resolved entry names a record the selector has left", () => {
|
||||
|
||||
@@ -122,9 +122,16 @@ export function resolveRecordCapabilities(
|
||||
): RecordCapabilities {
|
||||
if (accessRefused || recordSessionPending || contextUnproven) {
|
||||
return {
|
||||
accessRefused: accessRefused || undefined,
|
||||
recordSessionPending:
|
||||
recordSessionPending || contextUnproven || undefined,
|
||||
// An unprovable context is a REFUSAL, not a transient hold, and the
|
||||
// difference is whether there is a way out. `recordSessionPending`
|
||||
// renders `RecordScopeHydrationGate` — a bare spinner with no controls,
|
||||
// correct while a switch is genuinely in flight because it ends on its
|
||||
// own. This state does not end on its own: `/api/auth/me` reports the
|
||||
// same disagreement on every boot, so a spinner here is a wedge with no
|
||||
// exit. `accessRefused` renders `SharedRecordUnavailable`, which carries
|
||||
// a "leave this record" button that posts `switch(null)`.
|
||||
accessRefused: accessRefused || contextUnproven || undefined,
|
||||
recordSessionPending: recordSessionPending || undefined,
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
|
||||
Reference in new issue
Block a user