mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
A read-failure primitive decided from the status alone, so a 401 from a step-up or re-proof gate would have read as an ended session and sent the reader to sign-in. The code now decides first and the status is the fallback for codes it does not know. The two invitation routes answer an unknown name or e-mail with sharing.invite.no_account. Both have a second 404 with a different next step, and since every error now carries a code the clients match on it instead of on the status. The status defaults move into a module the client can import, and the contract text no longer says these arms carry no code.
841 lines
31 KiB
TypeScript
841 lines
31 KiB
TypeScript
/**
|
|
* The five verbs of account sharing, over the real routes, against real
|
|
* Postgres.
|
|
*
|
|
* Invite, accept, switch, revoke, renounce. Every one of them is a question
|
|
* about which rows exist afterwards, which is why none of it is unit-tested
|
|
* here: a mocked Prisma can show that the route called what it meant to call,
|
|
* and this feature has exactly one failure mode worth catching — the call that
|
|
* was made and did not land. The revocation cleanup is the sharpest case. A
|
|
* response body saying `sessionsCleared: 1` proves nothing; the session row
|
|
* still pointing at the owner's record is the defect.
|
|
*
|
|
* The delegable read handler is assembled here rather than imported because no
|
|
* shipped route declares `requireRecordAuth` yet — the frozen list lands empty
|
|
* on purpose and routes migrate into it later. Everything beneath it is real:
|
|
* the real wrapper, the real resolver, the real grant module, the real
|
|
* database.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
import { assertRecordContext, cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables, switchSessionTo } 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: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
import { prisma } from "@/lib/db";
|
|
import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope";
|
|
|
|
let counter = 0;
|
|
|
|
async function makeUser(label: string) {
|
|
const suffix = `${label}-${counter++}`;
|
|
return getPrismaClient().user.create({
|
|
data: {
|
|
username: `share-${suffix}`,
|
|
email: `share-${suffix}@example.test`,
|
|
displayName: `Share ${suffix}`,
|
|
role: "USER",
|
|
},
|
|
});
|
|
}
|
|
|
|
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);
|
|
// A new session is a new browser, and a new browser has adopted nothing. The
|
|
// reset matters where one test drives two of them: a stale adopted epoch
|
|
// carried across would make the second browser's first switch lose a
|
|
// compare-and-set it was never in.
|
|
adoptedEpoch = 0;
|
|
return session;
|
|
}
|
|
|
|
async function mintToken(userId: string): Promise<string> {
|
|
const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0");
|
|
await getPrismaClient().apiToken.create({
|
|
data: {
|
|
userId,
|
|
name: "sharing-lifecycle-test",
|
|
tokenHash: hashToken(raw),
|
|
permissions: ["*"],
|
|
},
|
|
});
|
|
return raw;
|
|
}
|
|
|
|
function jsonRequest(url: string, method: string, body?: unknown): NextRequest {
|
|
return new NextRequest(url, {
|
|
method,
|
|
headers: { "content-type": "application/json" },
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
});
|
|
}
|
|
|
|
// ── the routes under test ───────────────────────────────────────────────────
|
|
|
|
async function listGrants(): Promise<Response> {
|
|
const { GET } = await import("@/app/api/account/grants/route");
|
|
return GET();
|
|
}
|
|
|
|
async function invite(
|
|
identifier: string,
|
|
expiresAt?: string | null,
|
|
extra: Record<string, unknown> = {},
|
|
): Promise<Response> {
|
|
const { POST } = await import("@/app/api/account/grants/route");
|
|
return POST(
|
|
jsonRequest("http://localhost/api/account/grants", "POST", {
|
|
identifier,
|
|
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
...extra,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function accept(grantId: string): Promise<Response> {
|
|
const { POST } = await import("@/app/api/account/grants/[id]/accept/route");
|
|
return POST(
|
|
jsonRequest(
|
|
`http://localhost/api/account/grants/${grantId}/accept`,
|
|
"POST",
|
|
),
|
|
{ params: Promise.resolve({ id: grantId }) },
|
|
);
|
|
}
|
|
|
|
async function revoke(grantId: string): Promise<Response> {
|
|
const { DELETE } = await import("@/app/api/account/grants/[id]/route");
|
|
return DELETE(
|
|
jsonRequest(`http://localhost/api/account/grants/${grantId}`, "DELETE"),
|
|
{ params: Promise.resolve({ id: grantId }) },
|
|
);
|
|
}
|
|
|
|
async function renounce(grantId: string): Promise<Response> {
|
|
const { POST } = await import("@/app/api/account/grants/[id]/renounce/route");
|
|
return POST(
|
|
jsonRequest(
|
|
`http://localhost/api/account/grants/${grantId}/renounce`,
|
|
"POST",
|
|
),
|
|
{ params: Promise.resolve({ id: grantId }) },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Switch the way the shipped browser switches: name the epoch the client
|
|
* believes it is on, and ADOPT the context the response publishes.
|
|
*
|
|
* v1.37.0 — the adoption is not decoration. `POST /api/account/switch` and
|
|
* `GET /api/auth/me` are the only two responses a client may learn its record
|
|
* context from, and every same-origin request after a switch has to assert it
|
|
* or the record-session fence refuses. A fixture that switched and did not
|
|
* adopt would be testing the fence's pre-fence-bundle arm rather than the
|
|
* handshake this file is about — so the round trip is exercised here, which is
|
|
* the property the feature actually ships.
|
|
*/
|
|
async function switchTo(accountId: string | null): Promise<Response> {
|
|
const { POST } = await import("@/app/api/account/switch/route");
|
|
const response = await POST(
|
|
jsonRequest("http://localhost/api/account/switch", "POST", {
|
|
accountId,
|
|
expectedEpoch: adoptedEpoch,
|
|
}),
|
|
);
|
|
if (response.status === 200) {
|
|
const { recordSession } = (await response.clone().json()).data;
|
|
if (recordSession) {
|
|
adoptedEpoch = recordSession.epoch;
|
|
assertRecordContext(recordSession.epoch, recordSession.scope);
|
|
}
|
|
}
|
|
return response;
|
|
}
|
|
|
|
/** The epoch this fixture's browser currently believes it is on. */
|
|
let adoptedEpoch = 0;
|
|
|
|
/** A delegable read, exactly as a migrated route builds one. */
|
|
const delegableRead: (request: NextRequest) => Promise<Response> = apiHandler(
|
|
async () => {
|
|
const { user, actor } = await requireRecordAuth("read", "measurements");
|
|
const rows = await prisma.measurement.findMany({
|
|
where: { userId: user.id },
|
|
orderBy: { value: "asc" },
|
|
});
|
|
return NextResponse.json({
|
|
data: {
|
|
scopeUserId: user.id,
|
|
actorUserId: actor.id,
|
|
values: rows.map((r) => r.value),
|
|
},
|
|
error: null,
|
|
});
|
|
},
|
|
);
|
|
|
|
function readRecord(): Promise<Response> {
|
|
return delegableRead(
|
|
new NextRequest("http://localhost/api/test/delegable", { method: "GET" }),
|
|
);
|
|
}
|
|
|
|
async function seedWeight(userId: string, value: number) {
|
|
return getPrismaClient().measurement.create({
|
|
data: {
|
|
userId,
|
|
type: "WEIGHT",
|
|
value,
|
|
unit: "kg",
|
|
measuredAt: new Date(),
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
}
|
|
|
|
/** The acting-account column on one session row, read straight from the table. */
|
|
async function actingAsOf(sessionId: string): Promise<string | null> {
|
|
const row = await getPrismaClient().session.findUniqueOrThrow({
|
|
where: { id: sessionId },
|
|
select: { actingAsUserId: true },
|
|
});
|
|
return row.actingAsUserId;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
// Every test starts on a fresh browser that has adopted nothing.
|
|
adoptedEpoch = 0;
|
|
});
|
|
|
|
describe("the handshake, end to end", () => {
|
|
it("[J1-adult-levels-lifecycle] carries a household from invitation to revocation and leaves the right rows", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await seedWeight(owner.id, 81);
|
|
await seedWeight(delegate.id, 64);
|
|
|
|
// ── the owner invites ──
|
|
await signIn(owner.id);
|
|
const invited = await invite(delegate.username);
|
|
expect(invited.status).toBe(201);
|
|
const grantView = (await invited.json()).data;
|
|
expect(grantView.state).toBe("PENDING");
|
|
expect(grantView.access).toBe("READ");
|
|
expect(grantView.account.id).toBe(delegate.id);
|
|
|
|
// Pending confers nothing: the delegate cannot switch into a record they
|
|
// have only been offered.
|
|
const delegateSession = await signIn(delegate.id);
|
|
expect((await switchTo(owner.id)).status).toBe(403);
|
|
expect(await actingAsOf(delegateSession.id)).toBeNull();
|
|
|
|
// ── the delegate accepts ──
|
|
const accepted = await accept(grantView.id);
|
|
expect(accepted.status).toBe(200);
|
|
expect((await accepted.json()).data.state).toBe("ACTIVE");
|
|
|
|
const acceptedRow = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id: grantView.id },
|
|
});
|
|
expect(acceptedRow.acceptedAt).not.toBeNull();
|
|
|
|
// ── the delegate switches in and reads the owner's record ──
|
|
const switched = await switchTo(owner.id);
|
|
expect(switched.status).toBe(200);
|
|
expect((await switched.json()).data.actingAs.accountId).toBe(owner.id);
|
|
expect(await actingAsOf(delegateSession.id)).toBe(owner.id);
|
|
|
|
const read = await readRecord();
|
|
expect(read.status).toBe(200);
|
|
const readBody = (await read.json()).data;
|
|
expect(readBody.scopeUserId).toBe(owner.id);
|
|
expect(readBody.actorUserId).toBe(delegate.id);
|
|
// The delegate's own 64 kg row is seeded and must not appear. An empty
|
|
// list would satisfy a weaker assertion while hiding the exact failure
|
|
// this feature exists to avoid.
|
|
expect(readBody.values).toEqual([81]);
|
|
|
|
// ── the owner revokes ──
|
|
await signIn(owner.id);
|
|
const revoked = await revoke(grantView.id);
|
|
expect(revoked.status).toBe(200);
|
|
expect((await revoked.json()).data.sessionsCleared).toBe(1);
|
|
|
|
// The row survives as the consent record rather than being deleted.
|
|
const revokedRow = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id: grantView.id },
|
|
});
|
|
expect(revokedRow.revokedAt).not.toBeNull();
|
|
expect(revokedRow.revokedBy).toBe("GRANTOR");
|
|
expect(revokedRow.acceptedAt).not.toBeNull();
|
|
|
|
// The session row, not the response body. A cleanup that ran everywhere
|
|
// except the database would look identical from the outside.
|
|
expect(await actingAsOf(delegateSession.id)).toBeNull();
|
|
|
|
// And the delegate's next request is refused on the grant, independently
|
|
// of the session having been cleared.
|
|
cookieJar.set("healthlog_session", delegateSession.id);
|
|
await switchSessionTo(delegateSession.id, owner.id);
|
|
const afterRevoke = await readRecord();
|
|
expect(afterRevoke.status).toBe(403);
|
|
expect((await afterRevoke.json()).meta.errorCode).toBe(
|
|
"sharing.access.denied",
|
|
);
|
|
});
|
|
|
|
it("drops the delegate's other sessions when they renounce", async () => {
|
|
// Two browsers, the way a person actually has them: a phone sitting inside
|
|
// the owner's record and a laptop in their own account. Renouncing from
|
|
// the laptop has to evict the phone — a cleanup that only reached the
|
|
// session making the request would leave the other one inside a record
|
|
// whose grant no longer exists.
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
|
|
await signIn(owner.id);
|
|
const grantId = (await (await invite(delegate.email!)).json()).data.id;
|
|
|
|
const phone = await signIn(delegate.id);
|
|
await accept(grantId);
|
|
await switchTo(owner.id);
|
|
expect(await actingAsOf(phone.id)).toBe(owner.id);
|
|
|
|
const laptop = await signIn(delegate.id);
|
|
const gone = await renounce(grantId);
|
|
expect(gone.status).toBe(200);
|
|
expect((await gone.json()).data.sessionsCleared).toBe(1);
|
|
expect(await actingAsOf(phone.id)).toBeNull();
|
|
expect(await actingAsOf(laptop.id)).toBeNull();
|
|
|
|
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id: grantId },
|
|
});
|
|
expect(row.revokedBy).toBe("GRANTEE");
|
|
});
|
|
|
|
it("refuses a renunciation made from inside the record itself", async () => {
|
|
// Grant management is non-delegable without exception, and the delegate's
|
|
// own renounce is not carved out of that. The browser switches back first
|
|
// — one request, which the client makes on the way — and the fail-closed
|
|
// default stays a default rather than a default with an exception.
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await signIn(owner.id);
|
|
const grantId = (await (await invite(delegate.username)).json()).data.id;
|
|
|
|
const session = await signIn(delegate.id);
|
|
await accept(grantId);
|
|
await switchTo(owner.id);
|
|
|
|
const refused = await renounce(grantId);
|
|
expect(refused.status).toBe(403);
|
|
expect((await refused.json()).meta.errorCode).toBe("sharing.not_permitted");
|
|
|
|
// Out of the record, then out of the grant, both instant.
|
|
expect((await switchTo(null)).status).toBe(200);
|
|
expect((await renounce(grantId)).status).toBe(200);
|
|
expect(await actingAsOf(session.id)).toBeNull();
|
|
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id: grantId },
|
|
});
|
|
expect(row.revokedBy).toBe("GRANTEE");
|
|
});
|
|
|
|
it("clears only the sessions pointing at the record that ended", async () => {
|
|
// Two owners, one delegate with a browser in each. Ending one grant must
|
|
// not evict the other — a cleanup scoped to the delegate alone would pass
|
|
// every single-household test and quietly sign people out of a record
|
|
// nobody revoked.
|
|
const ownerA = await makeUser("owner-a");
|
|
const ownerB = await makeUser("owner-b");
|
|
const delegate = await makeUser("delegate");
|
|
|
|
await signIn(ownerA.id);
|
|
const grantA = (await (await invite(delegate.username)).json()).data.id;
|
|
await signIn(ownerB.id);
|
|
const grantB = (await (await invite(delegate.username)).json()).data.id;
|
|
|
|
const sessionA = await signIn(delegate.id);
|
|
await accept(grantA);
|
|
await switchTo(ownerA.id);
|
|
const sessionB = await signIn(delegate.id);
|
|
await accept(grantB);
|
|
await switchTo(ownerB.id);
|
|
|
|
await signIn(ownerA.id);
|
|
await revoke(grantA);
|
|
|
|
expect(await actingAsOf(sessionA.id)).toBeNull();
|
|
expect(await actingAsOf(sessionB.id)).toBe(ownerB.id);
|
|
});
|
|
});
|
|
|
|
describe("who may do what to a grant", () => {
|
|
async function pendingGrant() {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await signIn(owner.id);
|
|
const id = (await (await invite(delegate.username)).json()).data.id;
|
|
return { owner, delegate, id };
|
|
}
|
|
|
|
it("refuses an acceptance by anybody but the invited account", async () => {
|
|
const { id } = await pendingGrant();
|
|
const stranger = await makeUser("stranger");
|
|
await signIn(stranger.id);
|
|
|
|
const response = await accept(id);
|
|
expect(response.status).toBe(404);
|
|
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row.acceptedAt).toBeNull();
|
|
});
|
|
|
|
it("refuses a revocation by the delegate — renounce is their verb", async () => {
|
|
const { delegate, id } = await pendingGrant();
|
|
await signIn(delegate.id);
|
|
|
|
expect((await revoke(id)).status).toBe(404);
|
|
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row.revokedAt).toBeNull();
|
|
});
|
|
|
|
it("refuses a renunciation by the owner — revoke is theirs", async () => {
|
|
const { owner, id } = await pendingGrant();
|
|
await signIn(owner.id);
|
|
|
|
expect((await renounce(id)).status).toBe(404);
|
|
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row.revokedAt).toBeNull();
|
|
});
|
|
|
|
it("refuses a second live invitation and allows one after a revocation", async () => {
|
|
const { delegate, id } = await pendingGrant();
|
|
|
|
const second = await invite(delegate.username);
|
|
expect(second.status).toBe(409);
|
|
expect((await second.json()).meta.errorCode).toBe(
|
|
"sharing.invite.duplicate",
|
|
);
|
|
|
|
await revoke(id);
|
|
const third = await invite(delegate.username);
|
|
expect(third.status).toBe(201);
|
|
const newId = (await third.json()).data.id;
|
|
expect(newId).not.toBe(id);
|
|
|
|
// The history piles up rather than being reused: the first row is still
|
|
// there, still revoked, with its own dates.
|
|
const rows = await getPrismaClient().accountGrant.findMany({
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows[0].id).toBe(id);
|
|
expect(rows[0].revokedAt).not.toBeNull();
|
|
expect(rows[1].revokedAt).toBeNull();
|
|
});
|
|
|
|
it("refuses an account sharing with itself", async () => {
|
|
const owner = await makeUser("owner");
|
|
await signIn(owner.id);
|
|
|
|
const response = await invite(owner.username);
|
|
expect(response.status).toBe(422);
|
|
expect((await response.json()).meta.errorCode).toBe("sharing.invite.self");
|
|
expect(await getPrismaClient().accountGrant.count()).toBe(0);
|
|
});
|
|
|
|
it("says so when the identifier names nobody on this instance", async () => {
|
|
const owner = await makeUser("owner");
|
|
await signIn(owner.id);
|
|
|
|
const response = await invite("nobody-here@example.test");
|
|
expect(response.status).toBe(404);
|
|
const body = (await response.json()) as { meta?: { errorCode?: string } };
|
|
expect(body.meta?.errorCode).toBe("sharing.invite.no_account");
|
|
expect(await getPrismaClient().accountGrant.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("what an invitation opens, over the real route", () => {
|
|
/**
|
|
* The sections and the third level, from the posted body to the stored
|
|
* column. The unit suite reads the `data` object handed to Prisma; this
|
|
* reads the ROW, which is the only thing that proves the two nulls did not
|
|
* swap places on the way down — a JSON `null` in that column resolves
|
|
* fail-closed to "opens nothing", one keystroke from the value that means
|
|
* "opens everything".
|
|
*/
|
|
it("stores the picked sections and hands them back on the grant view", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await signIn(owner.id);
|
|
|
|
const response = await invite(delegate.username, undefined, {
|
|
scope: ["labs", "medications"],
|
|
});
|
|
expect(response.status).toBe(201);
|
|
expect((await response.json()).data.scope).toEqual(["medications", "labs"]);
|
|
|
|
const row = await getPrismaClient().accountGrant.findFirstOrThrow({
|
|
where: { grantorId: owner.id },
|
|
});
|
|
expect(row.scopeJson).toEqual(["labs", "medications"]);
|
|
});
|
|
|
|
it("stores the whole record as a SQL null, not as a JSON null", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await signIn(owner.id);
|
|
|
|
expect((await invite(delegate.username)).status).toBe(201);
|
|
|
|
// Read through raw SQL: the Prisma client surfaces both nulls as `null`
|
|
// in JS, so the only way to tell "no scope" from "the JSON value null" is
|
|
// to ask the column itself. The distinction is the whole difference
|
|
// between a grant that opens everything and one that opens nothing.
|
|
const rows = await getPrismaClient().$queryRaw<
|
|
{ is_null: boolean }[]
|
|
>`SELECT scope_json IS NULL AS is_null FROM account_grants WHERE grantor_id = ${owner.id}`;
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].is_null).toBe(true);
|
|
});
|
|
|
|
it("refuses an unknown section and writes nothing", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await signIn(owner.id);
|
|
|
|
const response = await invite(delegate.username, undefined, {
|
|
scope: ["labs", "bank_details"],
|
|
});
|
|
expect(response.status).toBe(422);
|
|
expect(await getPrismaClient().accountGrant.count()).toBe(0);
|
|
});
|
|
|
|
it("refuses a scope beside MANAGE and writes nothing", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await signIn(owner.id);
|
|
|
|
const response = await invite(delegate.username, undefined, {
|
|
access: "MANAGE",
|
|
scope: ["labs"],
|
|
});
|
|
expect(response.status).toBe(422);
|
|
expect(await getPrismaClient().accountGrant.count()).toBe(0);
|
|
});
|
|
|
|
it("refuses a Bearer invitation that presents no step-up elevation", async () => {
|
|
// v1.39.3: every invitation takes the recent-proof gate, and a token
|
|
// presents its proof as an `X-Step-Up` elevation. No shipped client
|
|
// offers access with a token. (MANAGE over Bearer still ends in 403
|
|
// `sharing.invite.manage_browser_only` once an elevation is presented;
|
|
// the unit suite covers that arm.)
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
const token = await mintToken(owner.id);
|
|
cookieJar.clear();
|
|
headerJar.set("authorization", `Bearer ${token}`);
|
|
|
|
for (const access of ["READ", "WRITE", "MANAGE"]) {
|
|
const response = await invite(delegate.username, undefined, { access });
|
|
expect(response.status).toBe(401);
|
|
expect((await response.json()).meta.errorCode).toBe(
|
|
"auth.stepup.required",
|
|
);
|
|
}
|
|
expect(await getPrismaClient().accountGrant.count()).toBe(0);
|
|
});
|
|
|
|
it("mints MANAGE from a browser session that carries no second factor", async () => {
|
|
// `requireFreshMfaIfEnrolled` gates the enrolled cohort only. An account
|
|
// with no second factor cannot produce a fresh-factor proof, so gating it
|
|
// would lock it out of a level it is entitled to offer — the same posture
|
|
// account deletion and the encrypted export already take.
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await signIn(owner.id);
|
|
|
|
const response = await invite(delegate.username, undefined, {
|
|
access: "MANAGE",
|
|
});
|
|
expect(response.status).toBe(201);
|
|
|
|
const row = await getPrismaClient().accountGrant.findFirstOrThrow({
|
|
where: { grantorId: owner.id },
|
|
});
|
|
expect(row.access).toBe("MANAGE");
|
|
expect(row.scopeJson).toBeNull();
|
|
});
|
|
|
|
it("refuses MANAGE from an enrolled session whose factor has gone stale", async () => {
|
|
// The step-up itself, over the real gate. The session below has a
|
|
// confirmed TOTP secret and a `mfaVerifiedAt` well outside the window, so
|
|
// the only thing standing between it and a manage grant is the gate.
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await getPrismaClient().user.update({
|
|
where: { id: owner.id },
|
|
data: { totpConfirmedAt: new Date() },
|
|
});
|
|
const session = await signIn(owner.id);
|
|
await getPrismaClient().session.update({
|
|
where: { id: session.id },
|
|
data: { mfaVerifiedAt: new Date(Date.now() - 60 * 60 * 1000) },
|
|
});
|
|
|
|
const response = await invite(delegate.username, undefined, {
|
|
access: "MANAGE",
|
|
});
|
|
expect(response.status).toBe(401);
|
|
// The recent-proof gate answers first; on an enrolled account it wants
|
|
// the same fresh second factor the MANAGE step-up does.
|
|
expect((await response.json()).meta.errorCode).toBe(
|
|
"auth.reproof.required",
|
|
);
|
|
expect(await getPrismaClient().accountGrant.count()).toBe(0);
|
|
});
|
|
|
|
it("mints MANAGE once that same session has proved the factor", async () => {
|
|
// The other half, so the leg above proves a GATE rather than a broken
|
|
// route: same account, same enrolment, a fresh stamp.
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await getPrismaClient().user.update({
|
|
where: { id: owner.id },
|
|
data: { totpConfirmedAt: new Date() },
|
|
});
|
|
const session = await signIn(owner.id);
|
|
await getPrismaClient().session.update({
|
|
where: { id: session.id },
|
|
data: { mfaVerifiedAt: new Date() },
|
|
});
|
|
|
|
const response = await invite(delegate.username, undefined, {
|
|
access: "MANAGE",
|
|
});
|
|
expect(response.status).toBe(201);
|
|
expect(await getPrismaClient().accountGrant.count()).toBe(1);
|
|
});
|
|
|
|
it("asks a read invitation for a recent proof too (v1.39.3)", async () => {
|
|
// Same stale-factor account. An invitation at any level outlives the
|
|
// session that sent it, so every level now takes the recent-proof gate;
|
|
// on an enrolled account that is a fresh second factor.
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await getPrismaClient().user.update({
|
|
where: { id: owner.id },
|
|
data: { totpConfirmedAt: new Date() },
|
|
});
|
|
const session = await signIn(owner.id);
|
|
await getPrismaClient().session.update({
|
|
where: { id: session.id },
|
|
data: { mfaVerifiedAt: new Date(Date.now() - 60 * 60 * 1000) },
|
|
});
|
|
|
|
const response = await invite(delegate.username);
|
|
expect(response.status).toBe(401);
|
|
expect((await response.json()).meta.errorCode).toBe(
|
|
"auth.reproof.required",
|
|
);
|
|
expect(await getPrismaClient().accountGrant.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("grant management cannot be reached from inside a switch", () => {
|
|
/**
|
|
* The property the design states as "a delegate must never grant, widen, or
|
|
* transfer". Not enforced by a re-delegation check in the handler — by the
|
|
* mode: bare `requireAuth()` refuses outright while a switch is active, so
|
|
* there is no path through these routes that runs as somebody else.
|
|
*/
|
|
async function switchedDelegate() {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
const third = await makeUser("third");
|
|
await signIn(owner.id);
|
|
const id = (await (await invite(delegate.username)).json()).data.id;
|
|
await signIn(delegate.id);
|
|
await accept(id);
|
|
await switchTo(owner.id);
|
|
return { owner, delegate, third, id };
|
|
}
|
|
|
|
it("refuses an invitation issued while acting as somebody else", async () => {
|
|
const { third } = await switchedDelegate();
|
|
|
|
const response = await invite(third.username);
|
|
expect(response.status).toBe(403);
|
|
expect((await response.json()).meta.errorCode).toBe(
|
|
"sharing.not_permitted",
|
|
);
|
|
// One grant in the table: the delegate's own. Nothing was widened.
|
|
expect(await getPrismaClient().accountGrant.count()).toBe(1);
|
|
});
|
|
|
|
it("refuses the grant list and the revoke while acting as somebody else", async () => {
|
|
const { id } = await switchedDelegate();
|
|
|
|
expect((await listGrants()).status).toBe(403);
|
|
expect((await revoke(id)).status).toBe(403);
|
|
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
|
where: { id },
|
|
});
|
|
expect(row.revokedAt).toBeNull();
|
|
});
|
|
|
|
it("still lets the switched session switch back", async () => {
|
|
const { delegate } = await switchedDelegate();
|
|
const session = await getPrismaClient().session.findFirstOrThrow({
|
|
where: { userId: delegate.id, actingAsUserId: { not: null } },
|
|
});
|
|
|
|
const back = await switchTo(null);
|
|
expect(back.status).toBe(200);
|
|
expect((await back.json()).data.actingAs).toBeNull();
|
|
expect(await actingAsOf(session.id)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("the switch endpoint", () => {
|
|
it("[J1-adult-levels-lifecycle] refuses a switch into a grant that is not active", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await signIn(owner.id);
|
|
const lapsing = (
|
|
await (
|
|
await invite(
|
|
delegate.username,
|
|
new Date(Date.now() + 300).toISOString(),
|
|
)
|
|
).json()
|
|
).data.id;
|
|
|
|
const session = await signIn(delegate.id);
|
|
await accept(lapsing);
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
|
|
const response = await switchTo(owner.id);
|
|
expect(response.status).toBe(403);
|
|
expect((await response.json()).meta.errorCode).toBe(
|
|
"sharing.access.denied",
|
|
);
|
|
expect(await actingAsOf(session.id)).toBeNull();
|
|
});
|
|
|
|
it("answers a nonexistent account and an ungranting one with the same bytes", async () => {
|
|
const stranger = await makeUser("stranger");
|
|
const caller = await makeUser("caller");
|
|
await signIn(caller.id);
|
|
|
|
const unknown = await switchTo("does-not-exist-at-all");
|
|
const ungranting = await switchTo(stranger.id);
|
|
|
|
expect(unknown.status).toBe(ungranting.status);
|
|
expect(await bodyWithoutRequestId(unknown)).toBe(
|
|
await bodyWithoutRequestId(ungranting),
|
|
);
|
|
});
|
|
|
|
it("refuses over Bearer, where the carrier is the header", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await signIn(owner.id);
|
|
const id = (await (await invite(delegate.username)).json()).data.id;
|
|
const session = await signIn(delegate.id);
|
|
await accept(id);
|
|
|
|
cookieJar.clear();
|
|
headerJar.set("authorization", `Bearer ${await mintToken(delegate.id)}`);
|
|
const response = await switchTo(owner.id);
|
|
expect(response.status).toBe(400);
|
|
expect((await response.json()).meta.errorCode).toBe(
|
|
"sharing.switch.wrong_transport",
|
|
);
|
|
// Nothing was stamped on the browser session that happens to exist.
|
|
expect(await actingAsOf(session.id)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("the grant list", () => {
|
|
it("shows both directions with the state the resolver would agree with", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
const other = await makeUser("other");
|
|
|
|
await signIn(owner.id);
|
|
const given = (await (await invite(delegate.username)).json()).data.id;
|
|
|
|
await signIn(other.id);
|
|
const received = (await (await invite(owner.username)).json()).data.id;
|
|
|
|
await signIn(owner.id);
|
|
const body = (await (await listGrants()).json()).data;
|
|
|
|
expect(body.given.map((g: { id: string }) => g.id)).toEqual([given]);
|
|
expect(body.given[0].account.id).toBe(delegate.id);
|
|
expect(body.given[0].state).toBe("PENDING");
|
|
expect(body.received.map((g: { id: string }) => g.id)).toEqual([received]);
|
|
expect(body.received[0].account.id).toBe(other.id);
|
|
|
|
// No e-mail addresses in either direction: a grant is not a way to collect
|
|
// the other party's contact details.
|
|
expect(JSON.stringify(body)).not.toContain("@example.test");
|
|
});
|
|
|
|
it("keeps ended grants in the list, marked as ended", async () => {
|
|
const owner = await makeUser("owner");
|
|
const delegate = await makeUser("delegate");
|
|
await signIn(owner.id);
|
|
const id = (await (await invite(delegate.username)).json()).data.id;
|
|
await revoke(id);
|
|
|
|
const body = (await (await listGrants()).json()).data;
|
|
expect(body.given).toHaveLength(1);
|
|
expect(body.given[0].state).toBe("REVOKED");
|
|
expect(body.given[0].revokedBy).toBe("GRANTOR");
|
|
});
|
|
});
|