Files
HealthLog/tests/integration/routes/auth-me-devices.test.ts
T
Marc-André Bombeck f353f55212 test(integration): sessions, devices, security activity and FHIR against Postgres
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.
2026-10-22 05:31:09 +02:00

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);
});
});