Files
HealthLog/tests/integration/session-time-zone.test.ts
Marc-André Bombeck b08d527045 test(auth): mint session fixtures in the shape the lookup accepts
The session time-zone test stored sessions under readable secrets that
the cookie shape check now refuses before the query, so it tested the
refusal instead of the expiry. A shared helper mints hls_ plus 32 hex
bytes, and the two route tests that set a session cookie use it too.
2026-10-20 10:56:55 +02:00

256 lines
8.7 KiB
TypeScript

/**
* HealthLog works on a database whose default time zone is not UTC.
*
* Stored times are UTC in `timestamp` columns without a zone; Postgres reads
* `now()` through the session time zone. On a shared instance set to
* Europe/Berlin a fresh passkey challenge used to be expired before the
* prompt closed. This file sets the test database's default to Europe/Berlin
* (every connection opened afterwards starts in it) and drives the expiry
* paths through the application's own client, which pins UTC per
* connection. A second block takes the pin away and shows that the raw-SQL
* expiry checks hold on their own.
*/
import { Client } from "pg";
import { PrismaPg } from "@prisma/adapter-pg";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { PrismaClient } from "@/generated/prisma/client";
import { fixtureSessionSecret } from "@/__tests__/helpers/session-secret";
import { getPrismaClient, truncateAllTables } from "./setup";
const ZONE = "Europe/Berlin";
const USER_ID = "user-session-time-zone";
let databaseName = "";
async function rawClient(): Promise<Client> {
// No startup options: the connection takes the database default.
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
return client;
}
async function setDatabaseZone(zone: string | null): Promise<void> {
const client = await rawClient();
try {
if (!databaseName) {
const { rows } = await client.query<{ name: string }>(
"SELECT current_database() AS name",
);
databaseName = rows[0].name;
}
await client.query(
zone
? `ALTER DATABASE "${databaseName}" SET timezone TO '${zone}'`
: `ALTER DATABASE "${databaseName}" RESET timezone`,
);
} finally {
await client.end();
}
}
/** A Prisma client WITHOUT the pin: its sessions run in Europe/Berlin. */
function unpinnedClient(): PrismaClient {
return new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }),
});
}
async function challenge(
db: PrismaClient,
type: string,
expiresInMs = 60_000,
): Promise<string> {
const row = await db.authChallenge.create({
data: {
userId: USER_ID,
challenge: `material-${type}`,
type,
expiresAt: new Date(Date.now() + expiresInMs),
},
});
return row.id;
}
beforeAll(async () => {
// Before any pooled connection of this file opens.
await setDatabaseZone(ZONE);
});
afterAll(async () => {
// The container is shared with every other file of the run.
await setDatabaseZone(null);
});
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: { id: USER_ID, username: "tz-user", email: "tz@example.test" },
});
});
describe("on a database whose default zone is Europe/Berlin", () => {
it("a connection without the pin really runs in Europe/Berlin", async () => {
const client = await rawClient();
try {
const { rows } = await client.query<{ TimeZone: string }>(
"SHOW TimeZone",
);
expect(rows[0].TimeZone).toBe(ZONE);
} finally {
await client.end();
}
});
it("the application's pool runs in UTC and the boot check says so", async () => {
const { runBootTimeZoneCheck } = await import("@/lib/boot/time-zone-check");
await expect(runBootTimeZoneCheck(getPrismaClient())).resolves.toEqual({
state: "utc",
timeZone: "UTC",
});
const unpinned = unpinnedClient();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await expect(runBootTimeZoneCheck(unpinned)).resolves.toEqual({
state: "mismatch",
timeZone: ZONE,
});
expect(warn).toHaveBeenCalledTimes(1);
} finally {
warn.mockRestore();
await unpinned.$disconnect();
}
});
it("a fresh passkey registration challenge is accepted", async () => {
const { consumeRegistrationChallenge } = await import("@/lib/auth/passkey");
const { hashToken } = await import("@/lib/auth/hmac");
const sessionId = "session-tz";
const id = await challenge(
getPrismaClient(),
`passkey-registration:v1:${hashToken(sessionId)}`,
);
const claim = await consumeRegistrationChallenge({
challengeId: id,
userId: USER_ID,
sessionId,
});
expect(claim.ok).toBe(true);
if (claim.ok) {
// Stamped by the column default: UTC, not Berlin wall time.
expect(Math.abs(claim.createdAt.getTime() - Date.now())).toBeLessThan(
60_000,
);
}
});
it("a fresh login challenge is claimed, an expired one is not", async () => {
const { claimAuthChallenge } = await import("@/lib/auth/passkey");
const fresh = await challenge(getPrismaClient(), "authentication");
await expect(
claimAuthChallenge({ challengeId: fresh, type: "authentication" }),
).resolves.not.toBeNull();
const stale = await challenge(getPrismaClient(), "authentication", -1_000);
await expect(
claimAuthChallenge({ challengeId: stale, type: "authentication" }),
).resolves.toBeNull();
});
it("a session is valid until it expires and is stamped in UTC", async () => {
const { validateSessionFromCookieValue } =
await import("@/lib/auth/session");
const { hashToken } = await import("@/lib/auth/hmac");
const liveSecret = fixtureSessionSecret();
const expiredSecret = fixtureSessionSecret();
const live = await getPrismaClient().session.create({
data: {
userId: USER_ID,
tokenHash: hashToken(liveSecret),
expiresAt: new Date(Date.now() + 60 * 60_000),
},
});
await getPrismaClient().session.create({
data: {
userId: USER_ID,
tokenHash: hashToken(expiredSecret),
expiresAt: new Date(Date.now() - 60_000),
},
});
expect(Math.abs(live.createdAt.getTime() - Date.now())).toBeLessThan(
60_000,
);
await expect(
validateSessionFromCookieValue(liveSecret),
).resolves.not.toBeNull();
await expect(
validateSessionFromCookieValue(expiredSecret),
).resolves.toBeNull();
});
it("the native sign-in handoff sees a session begun inside it as fresh", async () => {
// The handoff link is good only for a session created after it began:
// `session.createdAt >= startedAt`, with `startedAt` read from the
// database clock and `createdAt` stamped by the column default.
const { nativeHandoffDbNow } =
await import("@/lib/auth/native-web-handoff");
const startedAt = await nativeHandoffDbNow();
// The driver adapter reads a `timestamptz` as if its offset were +00:00,
// so on a Berlin session this instant came back two hours late.
expect(Math.abs(startedAt.getTime() - Date.now())).toBeLessThan(60_000);
const created = await getPrismaClient().$queryRaw<{ created_at: Date }[]>`
INSERT INTO sessions (id, user_id, token_hash, expires_at)
VALUES ('session-handoff', ${USER_ID}, 'handoff-hash',
(NOW() AT TIME ZONE 'UTC') + INTERVAL '1 hour')
RETURNING created_at
`;
const createdAt = created[0].created_at;
expect(createdAt.getTime()).toBeGreaterThanOrEqual(startedAt.getTime());
expect(createdAt.getTime() - startedAt.getTime()).toBeLessThan(60_000);
});
it("a rate-limit window resets one window from now", async () => {
const { checkRateLimit } = await import("@/lib/rate-limit");
const before = Date.now();
const result = await checkRateLimit("tz:window", 5, 60_000);
expect(result.allowed).toBe(true);
expect(result.resetAt - before).toBeGreaterThan(55_000);
expect(result.resetAt - before).toBeLessThan(65_000);
});
});
describe("without the per-connection pin", () => {
it("the raw-SQL challenge expiry still holds on a Berlin session", async () => {
const unpinned = unpinnedClient();
try {
const [{ TimeZone }] =
await unpinned.$queryRawUnsafe<{ TimeZone: string }[]>("SHOW TimeZone");
expect(TimeZone).toBe(ZONE);
vi.resetModules();
vi.doMock("@/lib/db", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/db")>()),
prisma: unpinned,
}));
const { claimAuthChallenge } = await import("@/lib/auth/passkey");
const fresh = await challenge(unpinned, "authentication");
await expect(
claimAuthChallenge({ challengeId: fresh, type: "authentication" }),
).resolves.not.toBeNull();
const stale = await challenge(unpinned, "authentication", -1_000);
await expect(
claimAuthChallenge({ challengeId: stale, type: "authentication" }),
).resolves.toBeNull();
} finally {
vi.doUnmock("@/lib/db");
await unpinned.$disconnect();
}
});
});