mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The mocked route tests for the session list and revoke, the device list and revoke, the security-activity feed and the FHIR surface move onto the kit. Each claim is now shown on the stored rows and on the next request: a revoked session, device login or token is refused when it is presented again, another account's session or device answers 404 and stays signed in, the current-session and current-device flags follow the credential that anchors them, and no row id, full IP or push token reaches a response. The FHIR read set, covered against a database only by the scope check until now, is driven with seeded readings and medications: every resource carries the seeded values, another account's rows never appear, a fhir:read token reaches every data route and an unrelated narrow token is refused, the module gate closes all eleven data routes, and $everything serves the same entries as the document builder with references that resolve inside the response. The module-gate inventory now names the new file as its behavioural proof.
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
/**
|
|
* `/api/auth/me/devices` and `/api/auth/me/devices/{id}`: the registered
|
|
* device list and the device revoke, against the real database and through
|
|
* the real API kit.
|
|
*
|
|
* What a person relies on here:
|
|
* - the list shows the account's own devices, most recently seen first,
|
|
* with their delivery channels, and nothing that could be replayed to
|
|
* send a notification (no push token, no APNs identifier);
|
|
* - the "this device" badge follows `X-Device-Id` only when that device
|
|
* holds a live refresh token of this account, so a forged header cannot
|
|
* move it;
|
|
* - the revoke reaches only the caller's own devices, removes the row,
|
|
* revokes every live refresh token bound to it and the access tokens they
|
|
* pair with, and those access tokens stop authenticating at once.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { GET } from "@/app/api/auth/me/devices/route";
|
|
import { DELETE } from "@/app/api/auth/me/devices/[id]/route";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
import type { Prisma, User } from "@/generated/prisma/client";
|
|
|
|
import { anonymous, callRoute, type RouteAuth } from "../../kit/call-route";
|
|
import {
|
|
makeSession,
|
|
makeToken,
|
|
makeUser,
|
|
uniqueSuffix,
|
|
type BearerFixture,
|
|
} from "../../kit/factories";
|
|
import { getPrismaClient, truncateAllTables } from "../setup";
|
|
|
|
const prisma = getPrismaClient();
|
|
|
|
type DeviceRow = {
|
|
id: string;
|
|
label: string;
|
|
platform: string;
|
|
appVersion: string | null;
|
|
locale: string | null;
|
|
channels: Array<"web_push" | "apns">;
|
|
lastSeenAt: string;
|
|
createdAt: string;
|
|
isCurrent: boolean;
|
|
};
|
|
|
|
type RevokeResult = {
|
|
id: string;
|
|
revoked: boolean;
|
|
refreshTokensRevoked: number;
|
|
accessTokensRevoked: number;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(prisma);
|
|
});
|
|
|
|
async function makeDevice(
|
|
user: Pick<User, "id">,
|
|
overrides: Partial<Omit<Prisma.DeviceUncheckedCreateInput, "userId">> = {},
|
|
) {
|
|
return prisma.device.create({
|
|
data: {
|
|
userId: user.id,
|
|
platform: "ios",
|
|
token: `device-token-${uniqueSuffix()}`,
|
|
bundleId: "io.healthlog.app",
|
|
...overrides,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A refresh token bound to a device, paired with an access token the way
|
|
* the native sign-in pairs them. `access: null` stores a refresh row with no
|
|
* access-token pairing.
|
|
*/
|
|
async function makeRefresh(
|
|
user: Pick<User, "id">,
|
|
deviceId: string,
|
|
options: { access?: BearerFixture | null; revokedAt?: Date } = {},
|
|
) {
|
|
return prisma.refreshToken.create({
|
|
data: {
|
|
userId: user.id,
|
|
tokenHash: createHash("sha256")
|
|
.update(`refresh-${uniqueSuffix()}`)
|
|
.digest("hex"),
|
|
deviceId,
|
|
accessTokenHash: options.access ? hashToken(options.access.token) : null,
|
|
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
revokedAt: options.revokedAt ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function listDevices(auth: RouteAuth, deviceIdHeader?: string) {
|
|
return callRoute<{ devices: DeviceRow[] }>(GET, {
|
|
method: "GET",
|
|
route: "/api/auth/me/devices",
|
|
auth,
|
|
headers: deviceIdHeader ? { "x-device-id": deviceIdHeader } : undefined,
|
|
});
|
|
}
|
|
|
|
describe("GET /api/auth/me/devices", () => {
|
|
it("answers 401 without a credential", async () => {
|
|
expect((await listDevices(anonymous)).status).toBe(401);
|
|
});
|
|
|
|
it("lists the caller's devices most recently seen first, with channels and no push identifiers", async () => {
|
|
const owner = await makeUser();
|
|
const other = await makeUser();
|
|
const phone = await makeDevice(owner, {
|
|
model: "iPhone 14 Pro",
|
|
locale: "en-US",
|
|
appVersion: "1.0",
|
|
apnsToken: "apns-secret-value",
|
|
apnsEnvironment: "production",
|
|
lastSeen: new Date("2026-05-10T12:00:00Z"),
|
|
});
|
|
const tablet = await makeDevice(owner, {
|
|
model: null,
|
|
lastSeen: new Date("2026-05-09T12:00:00Z"),
|
|
});
|
|
await makeDevice(other, { model: "Not yours" });
|
|
|
|
const res = await listDevices(await makeSession(owner));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.data.devices).toEqual([
|
|
{
|
|
id: phone.id,
|
|
label: "iPhone 14 Pro",
|
|
platform: "ios",
|
|
appVersion: "1.0",
|
|
locale: "en-US",
|
|
channels: ["web_push", "apns"],
|
|
lastSeenAt: "2026-05-10T12:00:00.000Z",
|
|
createdAt: phone.createdAt.toISOString(),
|
|
isCurrent: false,
|
|
},
|
|
expect.objectContaining({
|
|
id: tablet.id,
|
|
// No model: the bundle id labels the row rather than a blank.
|
|
label: "io.healthlog.app",
|
|
channels: ["web_push"],
|
|
isCurrent: false,
|
|
}),
|
|
]);
|
|
const serialised = JSON.stringify(res.body);
|
|
expect(serialised).not.toContain(phone.token);
|
|
expect(serialised).not.toContain("apns-secret-value");
|
|
});
|
|
|
|
it("marks the device named by X-Device-Id when it holds a live refresh token of this account", async () => {
|
|
const owner = await makeUser();
|
|
const phone = await makeDevice(owner);
|
|
const tablet = await makeDevice(owner);
|
|
const access = await makeToken(owner);
|
|
await makeRefresh(owner, tablet.id, { access });
|
|
|
|
const res = await listDevices(access, tablet.id);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.data.devices.map((d) => [d.id, d.isCurrent]).sort()).toEqual(
|
|
[
|
|
[phone.id, false],
|
|
[tablet.id, true],
|
|
].sort(),
|
|
);
|
|
});
|
|
|
|
it("ignores an X-Device-Id that no live refresh token of this account anchors", async () => {
|
|
const owner = await makeUser();
|
|
const other = await makeUser();
|
|
const forged = await makeDevice(owner);
|
|
const revoked = await makeDevice(owner);
|
|
const foreignAnchor = await makeDevice(owner);
|
|
await makeRefresh(owner, revoked.id, { revokedAt: new Date() });
|
|
// Another account's refresh row naming this device id anchors nothing.
|
|
await makeRefresh(other, foreignAnchor.id);
|
|
const access = await makeToken(owner);
|
|
|
|
for (const header of [forged.id, revoked.id, foreignAnchor.id]) {
|
|
const res = await listDevices(access, header);
|
|
expect(res.status).toBe(200);
|
|
expect(res.data.devices.some((d) => d.isCurrent)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("DELETE /api/auth/me/devices/{id}", () => {
|
|
it("answers 401 without a credential and leaves the device", async () => {
|
|
const owner = await makeUser();
|
|
const device = await makeDevice(owner);
|
|
|
|
const res = await callRoute(DELETE, {
|
|
method: "DELETE",
|
|
route: "/api/auth/me/devices/{id}",
|
|
params: { id: device.id },
|
|
auth: anonymous,
|
|
});
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(await prisma.device.count({ where: { id: device.id } })).toBe(1);
|
|
});
|
|
|
|
it("answers 404 for an id that does not exist", async () => {
|
|
const owner = await makeUser();
|
|
|
|
const res = await callRoute(DELETE, {
|
|
method: "DELETE",
|
|
route: "/api/auth/me/devices/{id}",
|
|
params: { id: "no-such-device" },
|
|
auth: await makeSession(owner),
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("answers 404 for another account's device and leaves it and its credentials alone", async () => {
|
|
const owner = await makeUser();
|
|
const other = await makeUser();
|
|
const theirs = await makeDevice(other);
|
|
const theirAccess = await makeToken(other);
|
|
const theirRefresh = await makeRefresh(other, theirs.id, {
|
|
access: theirAccess,
|
|
});
|
|
|
|
const res = await callRoute(DELETE, {
|
|
method: "DELETE",
|
|
route: "/api/auth/me/devices/{id}",
|
|
params: { id: theirs.id },
|
|
auth: await makeSession(owner),
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(await prisma.device.count({ where: { id: theirs.id } })).toBe(1);
|
|
const refresh = await prisma.refreshToken.findUniqueOrThrow({
|
|
where: { id: theirRefresh.id },
|
|
});
|
|
expect(refresh.revokedAt).toBeNull();
|
|
expect((await listDevices(theirAccess)).status).toBe(200);
|
|
});
|
|
|
|
it("deletes the device, revokes its refresh tokens and their paired access tokens, and those stop authenticating", async () => {
|
|
const owner = await makeUser();
|
|
const session = await makeSession(owner);
|
|
const phone = await makeDevice(owner, { model: "iPhone" });
|
|
const tablet = await makeDevice(owner);
|
|
const accessA = await makeToken(owner);
|
|
const accessB = await makeToken(owner);
|
|
const tabletAccess = await makeToken(owner);
|
|
const refreshA = await makeRefresh(owner, phone.id, { access: accessA });
|
|
const refreshB = await makeRefresh(owner, phone.id, { access: accessB });
|
|
const unpaired = await makeRefresh(owner, phone.id, { access: null });
|
|
const earlier = new Date("2026-01-01T00:00:00Z");
|
|
const alreadyRevoked = await makeRefresh(owner, phone.id, {
|
|
revokedAt: earlier,
|
|
});
|
|
const tabletRefresh = await makeRefresh(owner, tablet.id, {
|
|
access: tabletAccess,
|
|
});
|
|
|
|
const res = await callRoute<RevokeResult>(DELETE, {
|
|
method: "DELETE",
|
|
route: "/api/auth/me/devices/{id}",
|
|
params: { id: phone.id },
|
|
auth: session,
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.data).toEqual({
|
|
id: phone.id,
|
|
revoked: true,
|
|
refreshTokensRevoked: 3,
|
|
accessTokensRevoked: 2,
|
|
});
|
|
|
|
expect(await prisma.device.count({ where: { id: phone.id } })).toBe(0);
|
|
const refreshRows = await prisma.refreshToken.findMany({
|
|
where: { userId: owner.id },
|
|
});
|
|
const byId = new Map(refreshRows.map((r) => [r.id, r]));
|
|
for (const id of [refreshA.id, refreshB.id, unpaired.id]) {
|
|
expect(byId.get(id)?.revokedAt).not.toBeNull();
|
|
}
|
|
// An earlier revocation keeps its own timestamp.
|
|
expect(byId.get(alreadyRevoked.id)?.revokedAt).toEqual(earlier);
|
|
// The other device is untouched.
|
|
expect(byId.get(tabletRefresh.id)?.revokedAt).toBeNull();
|
|
expect(await prisma.device.count({ where: { id: tablet.id } })).toBe(1);
|
|
|
|
expect((await listDevices(accessA)).status).toBe(401);
|
|
expect((await listDevices(accessB)).status).toBe(401);
|
|
expect((await listDevices(tabletAccess)).status).toBe(200);
|
|
// Browser sessions live on their own rows and are not touched.
|
|
expect((await listDevices(session)).status).toBe(200);
|
|
|
|
const audit = await prisma.auditLog.findFirstOrThrow({
|
|
where: { userId: owner.id, action: "devices.revoke" },
|
|
});
|
|
expect(JSON.parse(audit.details ?? "{}")).toEqual({
|
|
deviceId: phone.id,
|
|
label: "iPhone",
|
|
refreshTokensRevoked: 3,
|
|
accessTokensRevoked: 2,
|
|
});
|
|
|
|
// A retry after a successful revoke finds nothing.
|
|
const retry = await callRoute(DELETE, {
|
|
method: "DELETE",
|
|
route: "/api/auth/me/devices/{id}",
|
|
params: { id: phone.id },
|
|
auth: session,
|
|
});
|
|
expect(retry.status).toBe(404);
|
|
});
|
|
});
|