mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
837 lines
31 KiB
TypeScript
837 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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|