mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge branch 'v1422/fixes' into release/v1.42.2
This commit is contained in:
commit
3c4e053dcd
83 files changed
+1808
-258
No files matched your search
@@ -9,7 +9,7 @@
|
||||
* prove both contracts against a real Postgres; a mocked client could not
|
||||
* detect a missing atomic increment.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
@@ -180,3 +180,64 @@ describe("reserveBudget (real Postgres)", () => {
|
||||
expect(row?.totalTokens).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The budget day turns at the account's own midnight, the boundary the daily
|
||||
* briefing uses. On the UTC day a Berlin account's first generations after
|
||||
* local midnight still counted against the previous day's spent ceiling.
|
||||
*/
|
||||
describe("the budget day is the account's local day", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("opens a fresh day at local midnight while the UTC day is still running", async () => {
|
||||
const { buildDateKey, reserveBudget } =
|
||||
await import("@/lib/ai/coach/budget");
|
||||
const user = await getPrismaClient().user.create({
|
||||
data: {
|
||||
username: "budget-berlin",
|
||||
email: "budget-berlin@example.test",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
// 22:30 UTC on 2026-06-19 is 00:30 on 2026-06-20 in Berlin (CEST).
|
||||
vi.useFakeTimers({
|
||||
toFake: ["Date"],
|
||||
now: new Date("2026-06-19T22:30:00.000Z"),
|
||||
});
|
||||
|
||||
// The evening used the whole ceiling, booked to the day that just ended.
|
||||
await getPrismaClient().coachUsage.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
dateKey: "2026-06-19",
|
||||
totalTokens: 1000,
|
||||
operatorTokens: 1000,
|
||||
messageCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const dateKey = await buildDateKey(user.id);
|
||||
expect(dateKey).toBe("2026-06-20");
|
||||
const first = await reserveBudget(
|
||||
user.id,
|
||||
200,
|
||||
dateKey,
|
||||
1000,
|
||||
"operator",
|
||||
"job",
|
||||
);
|
||||
expect(first.allowed).toBe(true);
|
||||
const rows = await getPrismaClient().coachUsage.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { dateKey: "asc" },
|
||||
select: { dateKey: true, totalTokens: true },
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ dateKey: "2026-06-19", totalTokens: 1000 },
|
||||
{ dateKey: "2026-06-20", totalTokens: 200 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -230,7 +230,9 @@ describe("POST /api/insights/chat — integration", () => {
|
||||
// the reservation is refunded so the ledger stays at the cap.
|
||||
const { userId } = await seedUserWithSession();
|
||||
const prisma = getPrismaClient();
|
||||
const dateKey = new Date().toISOString().slice(0, 10);
|
||||
// The account's own day, the key the route reserves under.
|
||||
const { buildDateKey } = await import("@/lib/ai/coach/budget");
|
||||
const dateKey = await buildDateKey(userId);
|
||||
// v1.21.0 — the daily cap is now provider-aware: the seeded user resolves
|
||||
// to a user-egress chain, so the applicable ceiling is USER_PLAN_CAP. Seed
|
||||
// the spend at that ceiling so the reservation trips the gate. (Seeding the
|
||||
|
||||
@@ -483,7 +483,7 @@ describe("the reaper", () => {
|
||||
const { readDailySpend, buildDateKey } =
|
||||
await import("@/lib/ai/coach/budget");
|
||||
expect(
|
||||
(await readDailySpend(user.id, buildDateKey())).total,
|
||||
(await readDailySpend(user.id, await buildDateKey(user.id))).total,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const { reapAiRuns } = await import("@/lib/documents/ai-runs/store");
|
||||
@@ -493,7 +493,9 @@ describe("the reaper", () => {
|
||||
timedOut: 0,
|
||||
deleted: 0,
|
||||
});
|
||||
expect((await readDailySpend(user.id, buildDateKey())).total).toBe(0);
|
||||
expect(
|
||||
(await readDailySpend(user.id, await buildDateKey(user.id))).total,
|
||||
).toBe(0);
|
||||
|
||||
const run = await getPrismaClient().documentAiRun.findUniqueOrThrow({
|
||||
where: { id: runId },
|
||||
|
||||
@@ -168,6 +168,20 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
/** The follow-up slices a progressing backfill queues, recorded. */
|
||||
const sent = vi.hoisted(() => ({
|
||||
jobs: [] as Array<{ queue: string; data: unknown; options: unknown }>,
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/jobs/boss-instance")>()),
|
||||
getGlobalBoss: () => ({
|
||||
send: async (queue: string, data: unknown, options: unknown) => {
|
||||
sent.jobs.push({ queue, data, options });
|
||||
return `job-${sent.jobs.length}`;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -212,6 +226,7 @@ beforeEach(async () => {
|
||||
fake.requestsByPath = new Map();
|
||||
fake.status429 = 0;
|
||||
fake.status401 = 0;
|
||||
sent.jobs = [];
|
||||
await seed();
|
||||
}, 120_000);
|
||||
|
||||
@@ -353,9 +368,19 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", ()
|
||||
fake.requestsByPath.get("heart-rate/dataPoints") ?? 0;
|
||||
return heartRatePagesSeen >= 3;
|
||||
};
|
||||
// It finished four collections, so it is progressing, not failing: it
|
||||
// ends quietly and queues the next slice of the import.
|
||||
await expect(
|
||||
runGoogleHealthBackfillForUser(USER, stopAfterThreeHeartRatePages),
|
||||
).rejects.toThrow(/incomplete/);
|
||||
).resolves.toMatchObject({ continued: true });
|
||||
expect(sent.jobs).toHaveLength(1);
|
||||
expect(sent.jobs[0]).toMatchObject({
|
||||
queue: "integration-backfill-admission",
|
||||
data: {
|
||||
kind: "google-health-backfill",
|
||||
data: { userId: USER, slice: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
const afterFirst =
|
||||
await getPrismaClient().googleHealthConnection.findUnique({
|
||||
@@ -373,9 +398,10 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", ()
|
||||
);
|
||||
expect(done).not.toContain("fetchHeartRate");
|
||||
|
||||
// Second attempt: the finished collections are not read again.
|
||||
// Second attempt, the queued slice: the finished collections are not
|
||||
// read again.
|
||||
fake.requestsByPath = new Map();
|
||||
await runGoogleHealthBackfillForUser(USER);
|
||||
await runGoogleHealthBackfillForUser(USER, undefined, 1);
|
||||
|
||||
expect(fake.requestsByPath.get("exercise/dataPoints")).toBeUndefined();
|
||||
expect(fake.requestsByPath.get("sleep/dataPoints")).toBeUndefined();
|
||||
@@ -392,6 +418,29 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", ()
|
||||
expect(afterSecond?.backfillProgress).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps failing a backfill whose slice finished nothing new", async () => {
|
||||
const { runGoogleHealthBackfillForUser } =
|
||||
await import("@/lib/jobs/google-health-backfill");
|
||||
|
||||
// A first slice finishes everything but the heart-rate stream.
|
||||
const stopAtThirdPage = () =>
|
||||
(fake.requestsByPath.get("heart-rate/dataPoints") ?? 0) >= 3;
|
||||
await runGoogleHealthBackfillForUser(USER, stopAtThirdPage);
|
||||
expect(sent.jobs).toHaveLength(1);
|
||||
|
||||
// The next slice cannot get through the heart-rate stream either: no
|
||||
// collection finishes, so this one is stuck and says so.
|
||||
fake.requestsByPath = new Map();
|
||||
await expect(
|
||||
runGoogleHealthBackfillForUser(USER, stopAtThirdPage, 1),
|
||||
).rejects.toThrow(/incomplete/);
|
||||
expect(sent.jobs).toHaveLength(1);
|
||||
const row = await getPrismaClient().googleHealthConnection.findUnique({
|
||||
where: { userId: USER },
|
||||
});
|
||||
expect(row?.backfillCompletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("never runs the hourly poll or a manual sync beside a running backfill", async () => {
|
||||
fake.holdAtPage = 2;
|
||||
fake.held = new Promise<void>((resolve) => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { aggregateBuckets } from "@/lib/rollups/measurement-read";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { windowWeighting } from "@/lib/measurements/day-statistic";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -65,6 +66,81 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The instant the summary fixtures count back from: noon UTC yesterday.
|
||||
*
|
||||
* They used to count back from `Date.now()`, with each reading a few more
|
||||
* minutes earlier than the last. Run between midnight and about 01:15 UTC,
|
||||
* the earliest of those minutes slid across midnight into the day before,
|
||||
* so a day gained a reading in another hour, and for a type whose day is a
|
||||
* mean of hourly means (pulse) the composed mean moved off the plain AVG the
|
||||
* tests compared it with: the suite went red for an hour every night. Noon
|
||||
* leaves every offset inside its own day, so the layout is the same at any
|
||||
* time the suite runs.
|
||||
*/
|
||||
function fixtureAnchor(): number {
|
||||
const now = new Date();
|
||||
return (
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) -
|
||||
DAY_MS +
|
||||
12 * 60 * 60 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live SQL over the raw rows for the summary's count / min / max / mean,
|
||||
* with the mean each type documents: a plain AVG over readings, or, for a
|
||||
* type whose day is a mean of hourly means (`windowWeighting(type) ===
|
||||
* "day"`), the mean of those per-day values.
|
||||
*/
|
||||
async function liveSummaryRows(userId: string, since: Date) {
|
||||
const prisma = getPrismaClient();
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
mean_value: number;
|
||||
day_mean_value: number;
|
||||
}>
|
||||
>`
|
||||
WITH hourly AS (
|
||||
SELECT m."type", date_trunc('day', m."measured_at") AS day,
|
||||
AVG(m."value") AS v
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId} AND m."measured_at" >= ${since}
|
||||
GROUP BY m."type", date_trunc('day', m."measured_at"),
|
||||
date_trunc('hour', m."measured_at")
|
||||
),
|
||||
daily AS (
|
||||
SELECT "type", day, AVG(v) AS v FROM hourly GROUP BY "type", day
|
||||
),
|
||||
by_day AS (
|
||||
SELECT "type", AVG(v)::double precision AS day_mean_value
|
||||
FROM daily GROUP BY "type"
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value,
|
||||
d.day_mean_value AS day_mean_value
|
||||
FROM measurements m
|
||||
JOIN by_day d ON d."type" = m."type"
|
||||
WHERE m."user_id" = ${userId} AND m."measured_at" >= ${since}
|
||||
GROUP BY m."type", d.day_mean_value
|
||||
`;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
expectedMean:
|
||||
windowWeighting(row.type) === "day" ? row.day_mean_value : row.mean_value,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("measurement rollups — integration", () => {
|
||||
it("writes a DAY rollup row that matches the inserted measurements", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
@@ -298,8 +374,8 @@ describe("measurement rollups — integration", () => {
|
||||
// type families exercise the per-type composition path. The
|
||||
// values are deliberately fractional so the round2 contract gets
|
||||
// a workout.
|
||||
const now = Date.now();
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const now = fixtureAnchor();
|
||||
const dayMs = DAY_MS;
|
||||
const seedRows: Array<{
|
||||
userId: string;
|
||||
type: "WEIGHT" | "PULSE";
|
||||
@@ -336,26 +412,7 @@ describe("measurement rollups — integration", () => {
|
||||
// the source of truth — the read-swap must produce the same
|
||||
// count/min/max/mean byte-for-byte.
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * dayMs);
|
||||
const live = await prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
mean_value: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type"
|
||||
`;
|
||||
const live = await liveSummaryRows(user.id, ninetyDaysAgo);
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
@@ -366,9 +423,10 @@ describe("measurement rollups — integration", () => {
|
||||
expect(summary.count).toBe(Number(liveRow.count));
|
||||
expect(summary.min).toBe(round2(liveRow.min_value));
|
||||
expect(summary.max).toBe(round2(liveRow.max_value));
|
||||
// The composed weighted mean equals AVG(value) over the same
|
||||
// rows; both run through `round2`.
|
||||
expect(summary.mean).toBe(round2(liveRow.mean_value));
|
||||
// The composed mean equals the type's documented mean over the same
|
||||
// rows (AVG, or the mean of hourly-mean days); both run through
|
||||
// `round2`.
|
||||
expect(summary.mean).toBe(round2(liveRow.expectedMean));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -741,8 +799,8 @@ describe("measurement rollups — integration", () => {
|
||||
|
||||
// 60 readings over 30 days, two types, deliberately spread so
|
||||
// anomalies + slopes + windowed avgs all have signal.
|
||||
const now = Date.now();
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const now = fixtureAnchor();
|
||||
const dayMs = DAY_MS;
|
||||
const seedRows: Array<{
|
||||
userId: string;
|
||||
type: "WEIGHT" | "PULSE";
|
||||
@@ -777,26 +835,7 @@ describe("measurement rollups — integration", () => {
|
||||
// Parallel live aggregation over the same 90-day window using the
|
||||
// exact heavy-aggregate shape the cold-mount fallback runs.
|
||||
const ninetyDaysAgo = new Date(now - 90 * dayMs);
|
||||
const live = await prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
mean_value: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type"
|
||||
`;
|
||||
const live = await liveSummaryRows(user.id, ninetyDaysAgo);
|
||||
|
||||
const aggregate = await buildComprehensiveAggregate(user.id);
|
||||
|
||||
@@ -811,7 +850,7 @@ describe("measurement rollups — integration", () => {
|
||||
expect(summary.count).toBe(Number(liveRow.count));
|
||||
expect(summary.min).toBe(round2(liveRow.min_value));
|
||||
expect(summary.max).toBe(round2(liveRow.max_value));
|
||||
expect(summary.mean).toBe(round2(liveRow.mean_value));
|
||||
expect(summary.mean).toBe(round2(liveRow.expectedMean));
|
||||
// Narrow aggregate fills the non-composable windowed columns.
|
||||
// Slope tuples and avg7/avg30 are sourced from live SQL even on
|
||||
// the rollup-fresh path so the values are present (not null) as
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("429 rate-limit headers (real Postgres)", () => {
|
||||
await getPrismaClient().coachUsage.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
dateKey: buildDateKey(),
|
||||
dateKey: await buildDateKey(TEST_USER_ID),
|
||||
totalTokens: USER_PLAN_CAP,
|
||||
messageCount: 1,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 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 { 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 live = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
tokenHash: hashToken("hls_live-secret"),
|
||||
expiresAt: new Date(Date.now() + 60 * 60_000),
|
||||
},
|
||||
});
|
||||
await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
tokenHash: hashToken("hls_expired-secret"),
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
},
|
||||
});
|
||||
expect(Math.abs(live.createdAt.getTime() - Date.now())).toBeLessThan(
|
||||
60_000,
|
||||
);
|
||||
await expect(
|
||||
validateSessionFromCookieValue("hls_live-secret"),
|
||||
).resolves.not.toBeNull();
|
||||
await expect(
|
||||
validateSessionFromCookieValue("hls_expired-secret"),
|
||||
).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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user