mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(sharing): publish which records an account may open
`GET /api/auth/me` grows an `accountAccess` block: the accounts this caller may act on, the one the browser is inside right now, and the booleans a UI binds directly — `canSwitch`, and `canWrite` per account. Every value is resolved server-side. A client that worked out what it may do from grant rows would be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. `canWrite` is false for every grant in v1 because invitations write READ only. It is resolved through the grant state machine rather than hardcoded, so the day a WRITE grant exists the answer changes without this file being edited. The route becomes an actor surface. It has to be: the switcher, the banner naming whose record is open, and the way back out all read this payload, and under a bare `requireAuth()` the whole app shell would refuse the moment a switch was on. Every field it returns is still the caller's own. The active record is published as a full entry, not an id to look up. A banner that had to join two fields could render unnamed the moment they disagreed; here the active entry is one of the listed ones, selected by the pass that built them. A stamp left behind by a grant that lapsed reads as not switched — the same answer the resolver gives the next delegated request. Not published: the other account's avatar. Those bytes are owner-scoped, so a URL here would resolve to a refusal and paint a broken image. Clients keep the initials fallback.
This commit is contained in:
1 parent
6a096a75eb
commit
562123d4d1
6 files changed
+562
-32
No files matched your search
@@ -83,6 +83,16 @@ describe("the acting-account carrier is read in one place", () => {
|
||||
// themselves call these two functions and never name the column, which is
|
||||
// what keeps this list from growing one entry per surface.
|
||||
"lib/sharing/acting-session.ts",
|
||||
// v1.36.0 — the only thing that PUBLISHES it, and it publishes the stamp
|
||||
// only after re-deciding it. The account payload has to say which record
|
||||
// the browser is inside so the banner can name a person, but the value it
|
||||
// prints is not the column: an entry reaches `active` solely by surviving
|
||||
// the same live-grant pass that builds the switcher list, so a stamp left
|
||||
// behind by a lapsed grant reads as "not switched" — the same answer the
|
||||
// resolver will independently give the next delegated request. Takes the
|
||||
// whole auth context for exactly this reason: had it taken the id, the
|
||||
// route would name the column too, and the list would grow per surface.
|
||||
"lib/sharing/account-access.ts",
|
||||
].sort();
|
||||
|
||||
it("no other file reads or writes the carrier column", () => {
|
||||
|
||||
@@ -252,13 +252,15 @@ const DELEGABLE_ROUTES: Record<string, string> = {};
|
||||
*
|
||||
* The intended members are the surfaces a switched session needs in order to
|
||||
* stay usable and to get back out: the account bootstrap payload, the switch
|
||||
* endpoint, logout, the native refresh route, the locale read. One of them
|
||||
* exists so far. The rest arrive as their own diffs here; naming them before
|
||||
* endpoint, logout, the native refresh route, the locale read. Two of them
|
||||
* exist so far. The rest arrive as their own diffs here; naming them before
|
||||
* they exist would freeze a guess.
|
||||
*/
|
||||
const ACTOR_ROUTES: Record<string, string> = {
|
||||
"app/api/account/switch/route.ts":
|
||||
"The way back out. Every other route refuses under a switch, so if this one did too a browser could enter a record and never leave it. It reads and writes exactly one row — the caller's own session — renders nothing of the owner's, and grants nothing: the account it stamps is validated against a live grant first, and the stamp is re-checked on every request after.",
|
||||
"app/api/auth/me/route.ts":
|
||||
"The app shell reads it on every boot, including while a switch is on: the switcher, the banner naming whose record is open, and the route back out all bind this payload. Every field it returns is the caller's own — their preferences, their modules, their identity — and the one field about the switch says only which records they may open and which they are inside. It reads no row of the owner's, and its `accountAccess` block grants nothing: the resolver re-checks the grant on every delegated request regardless of what this payload said a moment ago.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -266,7 +268,7 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* a formality by accident: every addition has to be counted here as well as
|
||||
* listed above, which is one more place a careless admission has to pass.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 1;
|
||||
const FROZEN_ENTRY_COUNT = 2;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
/**
|
||||
* `GET /api/auth/me` — the account payload, and the one surface that keeps
|
||||
* answering while the browser is acting on somebody else's record.
|
||||
*
|
||||
* v1.36.0 made it an ACTOR surface (`requireActorAuth`). It has to be: the
|
||||
* switcher, the banner naming whose record is open, and the way back out all
|
||||
* read this payload, and all three are about the person at the keyboard, not
|
||||
* the record they are looking at. Under a bare `requireAuth()` the whole app
|
||||
* shell would 403 the moment a switch was on.
|
||||
*
|
||||
* So every field below is still the CALLER's own — their preferences, their
|
||||
* modules, their identity — and the one field about the switch,
|
||||
* `accountAccess`, says so explicitly. Nothing here ever renders the owner's
|
||||
* data; a delegate reading their own display preferences while inside
|
||||
* somebody else's record is the correct behaviour and the reason the mode
|
||||
* exists (design §4: locale, theme and layout belong to the person, not the
|
||||
* record).
|
||||
*/
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
@@ -12,11 +30,13 @@ import {
|
||||
getOperatorModuleAvailability,
|
||||
} from "@/lib/modules/gate";
|
||||
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
|
||||
import { resolveAccountAccess } from "@/lib/sharing/account-access";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const auth = await requireActorAuth();
|
||||
const { user } = auth;
|
||||
|
||||
annotate({ action: { name: "auth.me" } });
|
||||
|
||||
@@ -27,33 +47,38 @@ export const GET = apiHandler(async () => {
|
||||
// cycle-tracking gate; no row is forced, a NULL toggle derives from
|
||||
// gender) is a Postgres round-trip. Running them sequentially added
|
||||
// the cookie hop to every /me — and /me sits on every app boot path.
|
||||
const [, , cycleProfile, modules, moduleAvailability] = await Promise.all([
|
||||
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
|
||||
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
|
||||
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
|
||||
// corrected here; an account that enrols (or has the policy lifted) loses
|
||||
// the redirect on the next /me read.
|
||||
syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
}),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
}),
|
||||
// v1.18.0 — resolved module enable/disable map for every toggleable
|
||||
// module. cycle/coach reflect their real delegated state (the cycle
|
||||
// gate / disableCoach + operator assistant flag); the rest read the
|
||||
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
|
||||
// hide a whole module surface end-to-end when its key is `false`.
|
||||
resolveModuleMap(user.id),
|
||||
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
|
||||
// The resolved `modules` map above already AND-s this in, so it cannot
|
||||
// distinguish operator-off from user-off. The Modules hub needs that
|
||||
// distinction to render an operator-disabled module as a read-only
|
||||
// "disabled server-wide" row instead of a live toggle that no-ops.
|
||||
getOperatorModuleAvailability(),
|
||||
]);
|
||||
const [, , cycleProfile, modules, moduleAvailability, accountAccess] =
|
||||
await Promise.all([
|
||||
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
|
||||
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
|
||||
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
|
||||
// corrected here; an account that enrols (or has the policy lifted) loses
|
||||
// the redirect on the next /me read.
|
||||
syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
}),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
}),
|
||||
// v1.18.0 — resolved module enable/disable map for every toggleable
|
||||
// module. cycle/coach reflect their real delegated state (the cycle
|
||||
// gate / disableCoach + operator assistant flag); the rest read the
|
||||
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
|
||||
// hide a whole module surface end-to-end when its key is `false`.
|
||||
resolveModuleMap(user.id),
|
||||
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
|
||||
// The resolved `modules` map above already AND-s this in, so it cannot
|
||||
// distinguish operator-off from user-off. The Modules hub needs that
|
||||
// distinction to render an operator-disabled module as a read-only
|
||||
// "disabled server-wide" row instead of a live toggle that no-ops.
|
||||
getOperatorModuleAvailability(),
|
||||
// v1.36.0 — which records this caller may open, which one they are
|
||||
// inside, and what they may do there. Every value resolved server-side;
|
||||
// see the module docblock for why none of it is left to the client.
|
||||
resolveAccountAccess(auth),
|
||||
]);
|
||||
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
|
||||
|
||||
// v1.7.0 — patient-identity fields for the health-record export. The
|
||||
@@ -142,5 +167,11 @@ export const GET = apiHandler(async () => {
|
||||
// this to show a "disabled server-wide" read-only row; everywhere else
|
||||
// the already-AND-ed `modules` map is the single gate to read.
|
||||
moduleAvailability,
|
||||
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
|
||||
// `active` is the record this session is inside (null when it is in its
|
||||
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
|
||||
// directly. Always present — an account with no grants gets an empty list,
|
||||
// not a missing field.
|
||||
accountAccess,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* v1.36.0 — the shape of the sharing block, and nothing else.
|
||||
*
|
||||
* Types only, with no imports at all, because both ends read them: the route
|
||||
* that builds the block (`account-access.ts`, which talks to Postgres) and the
|
||||
* client that renders it. Declaring them beside the server code would put a
|
||||
* module that imports Prisma on the client's import graph — erased at compile
|
||||
* today, one careless value export away from being bundled tomorrow. The
|
||||
* boundary is cheaper to keep than to rediscover.
|
||||
*
|
||||
* What these types are FOR is stated once here and holds everywhere they are
|
||||
* used: they carry resolved answers. `canWrite` and `canSwitch` are decisions
|
||||
* the server has already made; a client that recomputed either from the rest
|
||||
* of the payload would be the second program deciding one person's access.
|
||||
*/
|
||||
|
||||
/** One account this caller may act on. */
|
||||
export interface AccountAccessEntry {
|
||||
/** The account whose record it is — the value `POST /api/account/switch` takes. */
|
||||
accountId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
/** The grant's level. `"read"` throughout v1. */
|
||||
access: "read" | "write";
|
||||
/** May this caller change that record. Resolved server-side; `false` in v1. */
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
/** The `accountAccess` block on `GET /api/auth/me`. */
|
||||
export interface AccountAccess {
|
||||
/** Every account this caller may open, newest grant first. */
|
||||
accounts: AccountAccessEntry[];
|
||||
/**
|
||||
* The record this browser is inside right now, resolved to a full entry —
|
||||
* not an id the client has to look up. Null when it is in its own record,
|
||||
* and always null on the Bearer transport.
|
||||
*/
|
||||
active: AccountAccessEntry | null;
|
||||
/** Is there anywhere to switch to. */
|
||||
canSwitch: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to put in front of a person, given what the payload published.
|
||||
*
|
||||
* `displayName` when the account set one, the username otherwise. This is a
|
||||
* presentation fallback and not a derivation of anything the server decided —
|
||||
* both values are published, and which of the two reads better is the client's
|
||||
* business. Kept here so the banner, the switcher and the sharing panel cannot
|
||||
* disagree about what to call somebody.
|
||||
*/
|
||||
export function accountLabel(entry: {
|
||||
displayName: string | null;
|
||||
username: string;
|
||||
}): string {
|
||||
const named = entry.displayName?.trim();
|
||||
return named && named.length > 0 ? named : entry.username;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* v1.36.0 — what a caller may do with account sharing, resolved server-side.
|
||||
*
|
||||
* This module answers three questions for one caller, once per `/api/auth/me`:
|
||||
* which records may I open, which one am I inside right now, and what may I do
|
||||
* in it. Every answer is a VALUE, not an input to a client-side calculation.
|
||||
*
|
||||
* That is the whole design of this file and it is worth stating plainly,
|
||||
* because the tempting alternative is one line shorter: publish the grant rows
|
||||
* and let the client work out what they mean. Then two programs decide one
|
||||
* person's access — the resolver in `src/lib/api-handler.ts`, and whichever
|
||||
* client is being read today — and they answer differently the first time an
|
||||
* expiry, a revocation or a second access level appears. The client renders
|
||||
* `canSwitch` and `canWrite`; it never computes them, and there is nothing in
|
||||
* the payload it could compute them from.
|
||||
*
|
||||
* `canWrite` is `false` for every entry in v1, because `inviteGrant` writes
|
||||
* READ and only READ. It is resolved here through {@link grantAllows} against
|
||||
* the real row rather than hardcoded, so the day a WRITE grant exists the
|
||||
* answer changes without this file being edited — and, more importantly, so
|
||||
* that nobody has to remember that it should.
|
||||
*
|
||||
* What is deliberately NOT published: the other account's avatar. The avatar
|
||||
* bytes are owner-scoped (`/api/user/avatar/{id}` refuses any id but the
|
||||
* caller's own), so a URL here would resolve to a 403 and paint a broken
|
||||
* image. Opening that route to a delegate is a classification decision about
|
||||
* a route, not a decision the account payload gets to make on the side.
|
||||
* Clients paint the initials fallback they already have.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type {
|
||||
AccountAccess,
|
||||
AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
|
||||
|
||||
/**
|
||||
* Resolve the sharing block for one caller.
|
||||
*
|
||||
* The block is always present, even for an account with no grants in either
|
||||
* direction: an empty `accounts` array with `canSwitch: false` says "nobody has
|
||||
* shared anything with you", which is a different statement from a missing
|
||||
* field (which says "this server does not know about sharing"). Absence reads
|
||||
* as absence.
|
||||
*
|
||||
* Takes the whole auth context rather than a pair of ids, so the carrier column
|
||||
* is named here and in no route file — the boundary guard in
|
||||
* `src/__tests__/acting-account-boundary-guard.test.ts` keeps that list short
|
||||
* on purpose, and a field plucked out at every call site is how such a list
|
||||
* grows one entry per surface.
|
||||
*
|
||||
* The session's stamp is deliberately NOT trusted to mean access: an entry
|
||||
* appears in `active` only if the same pass that
|
||||
* built `accounts` admitted it. So a stamp left behind by a grant that lapsed
|
||||
* between two requests reads as "not switched" here, which is exactly what the
|
||||
* request resolver will independently decide about the next delegated read.
|
||||
* The client's job on seeing that mismatch is to clear the stamp, not to
|
||||
* reason about it.
|
||||
*/
|
||||
export async function resolveAccountAccess(auth: {
|
||||
user: { id: string };
|
||||
session: { actingAsUserId?: string | null };
|
||||
}): Promise<AccountAccess> {
|
||||
const actorId = auth.user.id;
|
||||
const stamped = auth.session.actingAsUserId ?? null;
|
||||
const now = new Date();
|
||||
|
||||
// Narrow in SQL on the one condition the partial unique index makes cheap,
|
||||
// and let the state machine decide the rest — the same split
|
||||
// `findActiveGrant` uses, and for the same reason: a SQL predicate spelling
|
||||
// out "accepted and not expired" beside a TypeScript one is two places
|
||||
// deciding one question.
|
||||
const rows = await prisma.accountGrant.findMany({
|
||||
where: { granteeId: actorId, revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
grantor: { select: { id: true, username: true, displayName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const accounts: AccountAccessEntry[] = rows
|
||||
.filter((grant) => isGrantActive(grant, now))
|
||||
.map((grant) => ({
|
||||
accountId: grant.grantor.id,
|
||||
username: grant.grantor.username,
|
||||
displayName: grant.grantor.displayName,
|
||||
access: grant.access === "WRITE" ? "write" : "read",
|
||||
canWrite: grantAllows(grant, "write", now),
|
||||
}));
|
||||
|
||||
const active =
|
||||
stamped === null
|
||||
? null
|
||||
: (accounts.find((a) => a.accountId === stamped) ?? null);
|
||||
|
||||
return { accounts, active, canSwitch: accounts.length > 0 };
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* The `accountAccess` block on `GET /api/auth/me`, over the real route,
|
||||
* against real Postgres.
|
||||
*
|
||||
* The block is the only thing either client is allowed to know about its own
|
||||
* sharing permissions, so what is tested here is not "the field is present"
|
||||
* but "the field says the same thing the request resolver would". Every case
|
||||
* below is a state the grant table can actually be in — pending, active,
|
||||
* expired, revoked, self — and the question each time is whether the payload
|
||||
* would let a client paint a switcher entry the server would then refuse.
|
||||
*
|
||||
* Nothing is mocked below the route. A unit test with a stubbed Prisma could
|
||||
* prove the route calls the resolver; it could not prove that a grant which
|
||||
* lapsed an hour ago drops out of the list, which is the property the whole
|
||||
* block exists to carry.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { ACCOUNT_SELECTOR_HEADER } from "@/lib/api-handler";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser(label: string) {
|
||||
const suffix = `${label}-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `access-${suffix}`,
|
||||
email: `access-${suffix}@example.test`,
|
||||
displayName: `Access ${suffix}`,
|
||||
role: "USER",
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
interface AccountAccessEntry {
|
||||
accountId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
access: "read" | "write";
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
interface AccountAccessBlock {
|
||||
accounts: AccountAccessEntry[];
|
||||
active: AccountAccessEntry | null;
|
||||
canSwitch: boolean;
|
||||
}
|
||||
|
||||
async function readMe(): Promise<{
|
||||
status: number;
|
||||
accountAccess: AccountAccessBlock;
|
||||
id: string;
|
||||
}> {
|
||||
const { GET } = await import("@/app/api/auth/me/route");
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { id: string; accountAccess: AccountAccessBlock };
|
||||
};
|
||||
return {
|
||||
status: res.status,
|
||||
accountAccess: body.data?.accountAccess,
|
||||
id: body.data?.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** An accepted, never-ending grant from `grantorId` to `granteeId`. */
|
||||
async function grantAccess(
|
||||
grantorId: string,
|
||||
granteeId: string,
|
||||
extra: { expiresAt?: Date | null; acceptedAt?: Date | null } = {},
|
||||
) {
|
||||
return getPrismaClient().accountGrant.create({
|
||||
data: {
|
||||
grantorId,
|
||||
granteeId,
|
||||
access: "READ",
|
||||
invitedAt: new Date(),
|
||||
acceptedAt: extra.acceptedAt === undefined ? new Date() : extra.acceptedAt,
|
||||
expiresAt: extra.expiresAt ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("accountAccess — what the payload publishes", () => {
|
||||
it("is present and empty for an account nobody has shared with", async () => {
|
||||
const alone = await makeUser("alone");
|
||||
await signIn(alone.id);
|
||||
|
||||
const { status, accountAccess } = await readMe();
|
||||
|
||||
expect(status).toBe(200);
|
||||
// Present, not missing. A client that got no field could not tell "this
|
||||
// server does not do sharing" from "nobody shared anything with me", and
|
||||
// would have to guess which.
|
||||
expect(accountAccess).toEqual({
|
||||
accounts: [],
|
||||
active: null,
|
||||
canSwitch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("lists an accepted grant with the level resolved, and canWrite false", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await grantAccess(owner.id, delegate.id);
|
||||
await signIn(delegate.id);
|
||||
|
||||
const { accountAccess } = await readMe();
|
||||
|
||||
expect(accountAccess.accounts).toEqual([
|
||||
{
|
||||
accountId: owner.id,
|
||||
username: owner.username,
|
||||
displayName: owner.displayName,
|
||||
access: "read",
|
||||
canWrite: false,
|
||||
},
|
||||
]);
|
||||
expect(accountAccess.canSwitch).toBe(true);
|
||||
expect(accountAccess.active).toBeNull();
|
||||
});
|
||||
|
||||
it("omits a pending invitation — being offered access is not having it", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await grantAccess(owner.id, delegate.id, { acceptedAt: null });
|
||||
await signIn(delegate.id);
|
||||
|
||||
const { accountAccess } = await readMe();
|
||||
|
||||
expect(accountAccess.accounts).toEqual([]);
|
||||
expect(accountAccess.canSwitch).toBe(false);
|
||||
});
|
||||
|
||||
it("omits a grant that has lapsed, against the request clock", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await grantAccess(owner.id, delegate.id, {
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
await signIn(delegate.id);
|
||||
|
||||
const { accountAccess } = await readMe();
|
||||
|
||||
expect(accountAccess.accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits a revoked grant", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const grant = await grantAccess(owner.id, delegate.id);
|
||||
await getPrismaClient().accountGrant.update({
|
||||
where: { id: grant.id },
|
||||
data: { revokedAt: new Date(), revokedBy: "GRANTOR" },
|
||||
});
|
||||
await signIn(delegate.id);
|
||||
|
||||
const { accountAccess } = await readMe();
|
||||
|
||||
expect(accountAccess.accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it("never lists a grant this account GAVE — the block is about what it may open", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await grantAccess(owner.id, delegate.id);
|
||||
// The owner signs in. They gave the access; they did not receive any.
|
||||
await signIn(owner.id);
|
||||
|
||||
const { accountAccess } = await readMe();
|
||||
|
||||
expect(accountAccess.accounts).toEqual([]);
|
||||
expect(accountAccess.canSwitch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accountAccess — the active record", () => {
|
||||
it("resolves the stamped account to a full entry, not just an id", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await grantAccess(owner.id, delegate.id);
|
||||
const session = await signIn(delegate.id);
|
||||
await getPrismaClient().session.update({
|
||||
where: { id: session.id },
|
||||
data: { actingAsUserId: owner.id },
|
||||
});
|
||||
|
||||
const { accountAccess, id } = await readMe();
|
||||
|
||||
// Still the delegate's own payload — the switch changes which record the
|
||||
// delegable routes read, never whose preferences /me returns.
|
||||
expect(id).toBe(delegate.id);
|
||||
expect(accountAccess.active).toEqual({
|
||||
accountId: owner.id,
|
||||
username: owner.username,
|
||||
displayName: owner.displayName,
|
||||
access: "read",
|
||||
canWrite: false,
|
||||
});
|
||||
// The active entry is one of the listed ones, by construction. A banner
|
||||
// that had to join the two could render unnamed when they disagreed.
|
||||
expect(accountAccess.accounts).toContainEqual(accountAccess.active);
|
||||
});
|
||||
|
||||
it("reads as not-switched when the stamp outlives the grant", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const grant = await grantAccess(owner.id, delegate.id);
|
||||
const session = await signIn(delegate.id);
|
||||
await getPrismaClient().session.update({
|
||||
where: { id: session.id },
|
||||
data: { actingAsUserId: owner.id },
|
||||
});
|
||||
// The grant lapses while the browser sits inside the record. Expiry has
|
||||
// no session cleanup — only revocation does — so the stamp survives it.
|
||||
await getPrismaClient().accountGrant.update({
|
||||
where: { id: grant.id },
|
||||
data: { expiresAt: new Date(Date.now() - 1000) },
|
||||
});
|
||||
|
||||
const { accountAccess } = await readMe();
|
||||
|
||||
// The payload agrees with what the resolver will decide about the next
|
||||
// delegated read: there is no live grant, so there is no active record.
|
||||
// The client's job is to clear the stamp, which is what the
|
||||
// `sharing.access.denied` reset does.
|
||||
expect(accountAccess.active).toBeNull();
|
||||
expect(accountAccess.accounts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accountAccess — the actor surface refuses a selector header", () => {
|
||||
it("refuses a Bearer caller that attaches an account selector", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await grantAccess(owner.id, delegate.id);
|
||||
|
||||
const raw = `hlk_${delegate.id}-token`.padEnd(20, "0");
|
||||
await getPrismaClient().apiToken.create({
|
||||
data: {
|
||||
userId: delegate.id,
|
||||
name: "account-access-test",
|
||||
tokenHash: hashToken(raw),
|
||||
permissions: ["*"],
|
||||
},
|
||||
});
|
||||
|
||||
cookieJar.clear();
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
headerJar.set(ACCOUNT_SELECTOR_HEADER, owner.id);
|
||||
|
||||
const { GET } = await import("@/app/api/auth/me/route");
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
|
||||
// The account payload is DEFINED as the caller's own. A client that
|
||||
// attached a selector to it believes it is asking about the owner and is
|
||||
// not; answering correctly to the wrong question is how a support ticket
|
||||
// about "data that looks wrong" starts.
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.meta?.errorCode).toBe("sharing.not_permitted");
|
||||
});
|
||||
|
||||
it("serves a Bearer caller with no selector, and reports no active record", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await grantAccess(owner.id, delegate.id);
|
||||
|
||||
const raw = `hlk_${delegate.id}-plain`.padEnd(20, "0");
|
||||
await getPrismaClient().apiToken.create({
|
||||
data: {
|
||||
userId: delegate.id,
|
||||
name: "account-access-test",
|
||||
tokenHash: hashToken(raw),
|
||||
permissions: ["*"],
|
||||
},
|
||||
});
|
||||
|
||||
cookieJar.clear();
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
|
||||
const { status, accountAccess } = await readMe();
|
||||
|
||||
expect(status).toBe(200);
|
||||
// The token transport carries its selector per request, so this payload
|
||||
// is never inside a record. `accounts` still answers "which records may
|
||||
// I open", which is what the native switcher renders.
|
||||
expect(accountAccess.active).toBeNull();
|
||||
expect(accountAccess.accounts).toHaveLength(1);
|
||||
expect(accountAccess.canSwitch).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user