From b60fa0123b170f88738569bca7ac2dec2cbb4832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:13:37 +0200 Subject: [PATCH] test(integrations): freeze that every OAuth provider refreshes through the kit --- src/__tests__/oauth-kit-guard.test.ts | 114 ++++++++++++++ .../oauth-kit/__tests__/descriptors.test.ts | 148 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/__tests__/oauth-kit-guard.test.ts create mode 100644 src/lib/integrations/oauth-kit/__tests__/descriptors.test.ts diff --git a/src/__tests__/oauth-kit-guard.test.ts b/src/__tests__/oauth-kit-guard.test.ts new file mode 100644 index 000000000..a5e2ca106 --- /dev/null +++ b/src/__tests__/oauth-kit-guard.test.ts @@ -0,0 +1,114 @@ +/** + * Every OAuth provider integration goes through the one token lifecycle. + * + * `src/lib/integrations/oauth-kit/token.ts` owns reading a grant, deciding it + * needs a refresh, refreshing under the advisory lock, and writing the result + * back conditionally. It replaced seven copies of that sequence, which had + * drifted into four different safety levels, one of them a status route that + * refreshed a one-time token with no guard at all. This file is what stops an + * eighth copy: + * + * A. Each kit provider has `src/lib//oauth-descriptor.ts`, and the + * descriptor names that provider. + * B. A provider client's `refreshAccessToken` is imported by that + * provider's descriptor and by nothing else outside tests. + * C. The refresh lock is taken by the kit and by the AI provider store + * (`src/lib/ai/provider.ts`, which is not an integration) only. + * D. A token is sealed into a connection row only by a callback route + * (the connect-time write). The kit writes ciphertext the descriptor is + * handed and never encrypts on its own. + * + * Fitbit is the one listed exception: the classic Fitbit Web API is retired + * on 2026-10-30 and its integration rides out on its own refresh until then, + * rather than being migrated onto a lifecycle it will never use. + * + * Every leg asserts a non-empty match set before it compares, so a matcher + * that stops matching fails instead of passing. + */ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** The providers whose tokens the kit manages. */ +const KIT_PROVIDERS = [ + "withings", + "whoop", + "oura", + "strava", + "google-health", + "polar", +] as const; + +/** Retired on 2026-10-30; keeps its own refresh until it is removed. */ +const RETIRED = new Set(["src/lib/fitbit/sync-core.ts"]); + +function productionFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((f) => !f.startsWith("generated/")) + .filter((f) => !f.includes("__tests__/") && !/\.test\.tsx?$/.test(f)) + .map((f) => `src/${f}`); +} + +function read(rel: string): string { + return readFileSync(join(process.cwd(), rel), "utf8"); +} + +describe("OAuth token lifecycle guard", () => { + const files = productionFiles(); + + it("A: every kit provider has a descriptor naming it", () => { + for (const provider of KIT_PROVIDERS) { + const rel = `src/lib/${provider}/oauth-descriptor.ts`; + expect(existsSync(join(process.cwd(), rel)), rel).toBe(true); + expect(read(rel)).toMatch(new RegExp(`provider:\\s*"${provider}"`)); + } + }); + + it("B: a provider's refreshAccessToken is imported only by its descriptor", () => { + const importers = files.filter((f) => { + const src = read(f); + return /import\s*\{[^}]*\brefreshAccessToken\b[^}]*\}\s*from\s*["'](?:\.\/client|@\/lib\/[a-z-]+\/client)["']/.test( + src, + ); + }); + expect(importers.length).toBeGreaterThan(0); + const allowed = new Set([ + ...KIT_PROVIDERS.filter((p) => p !== "polar").map( + (p) => `src/lib/${p}/oauth-descriptor.ts`, + ), + ...RETIRED, + ]); + expect(importers.filter((f) => !allowed.has(f))).toEqual([]); + }); + + it("C: only the kit and the AI provider store take the refresh lock", () => { + const callers = files.filter((f) => + /\bacquireProviderTokenRefreshLock\s*\(/.test(read(f)), + ); + expect(callers.length).toBeGreaterThan(0); + expect(callers.sort()).toEqual([ + "src/lib/ai/provider.ts", + "src/lib/integrations/oauth-kit/token.ts", + "src/lib/integrations/oauth-refresh.ts", + ]); + }); + + it("D: tokens are sealed into a connection only by a callback", () => { + const sealing = files.filter((f) => + /\b(?:accessToken|refreshToken|[a-z]+AccessTokenEncrypted|[a-z]+RefreshTokenEncrypted):\s*encrypt\(/.test( + read(f), + ), + ); + expect(sealing.length).toBeGreaterThan(0); + const offenders = sealing.filter( + (f) => + !RETIRED.has(f) && + !/^src\/app\/api\/[a-z-]+\/callback\/route\.ts$/.test(f), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/lib/integrations/oauth-kit/__tests__/descriptors.test.ts b/src/lib/integrations/oauth-kit/__tests__/descriptors.test.ts new file mode 100644 index 000000000..4148ecfcc --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/descriptors.test.ts @@ -0,0 +1,148 @@ +/** + * Pins what each provider's descriptor reads and writes. The kit's own + * behaviour is covered in `token.test.ts`; the claim here is that every + * descriptor reads the columns its provider always used (so no connection has + * to reconnect) and writes only while the stored refresh token is the one the + * refresh spent. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/withings/credentials", () => ({ + getUserWithingsCredentials: vi.fn(), +})); +vi.mock("@/lib/whoop/credentials", () => ({ + getUserWhoopCredentials: vi.fn(), +})); +vi.mock("@/lib/google-health/credentials", () => ({ + getUserGoogleHealthCredentials: vi.fn(), +})); +vi.mock("@/lib/oura/credentials", () => ({ + getOuraClientCredentials: vi.fn(), +})); +vi.mock("@/lib/strava/credentials", () => ({ + getStravaClientCredentials: vi.fn(), +})); + +import type { OAuthKitDb, OAuthTokenDescriptor } from "../token"; +import { withingsTokenDescriptor } from "@/lib/withings/oauth-descriptor"; +import { whoopTokenDescriptor } from "@/lib/whoop/oauth-descriptor"; +import { googleHealthTokenDescriptor } from "@/lib/google-health/oauth-descriptor"; +import { ouraTokenDescriptor } from "@/lib/oura/oauth-descriptor"; +import { stravaTokenDescriptor } from "@/lib/strava/oauth-descriptor"; +import { polarTokenDescriptor } from "@/lib/polar/oauth-descriptor"; + +function fakeDb(row: Record | null) { + const model = { + findUnique: vi.fn(async () => row), + updateMany: vi.fn(async () => ({ count: 1 })), + }; + const db = { + user: model, + withingsConnection: model, + whoopConnection: model, + googleHealthConnection: model, + } as unknown as OAuthKitDb; + return { db, model }; +} + +const EXP = new Date("2026-10-21T13:00:00.000Z"); +const NEXT = { + accessCiphertext: "a2", + refreshCiphertext: "r2", + expiresAt: EXP, +}; + +type AnyDescriptor = OAuthTokenDescriptor; + +const CONNECTION_TABLES: Array<[string, AnyDescriptor]> = [ + ["withings", withingsTokenDescriptor as unknown as AnyDescriptor], + ["whoop", whoopTokenDescriptor as unknown as AnyDescriptor], + ["google-health", googleHealthTokenDescriptor as unknown as AnyDescriptor], +]; + +describe.each(CONNECTION_TABLES)("%s descriptor", (_name, d) => { + it("reads the connection row's columns", async () => { + const { db } = fakeDb({ + whoopUserId: "w", + accessToken: "a1", + refreshToken: "r1", + tokenExpiresAt: EXP, + }); + await expect(d.readGrant(db, "u")).resolves.toEqual({ + accessCiphertext: "a1", + refreshCiphertext: "r1", + expiresAt: EXP, + }); + }); + + it("writes only over the refresh token it spent", async () => { + const { db, model } = fakeDb(null); + await d.writeGrant(db, "u", "r1", NEXT); + expect(model.updateMany).toHaveBeenCalledWith({ + where: { userId: "u", refreshToken: "r1" }, + data: { accessToken: "a2", refreshToken: "r2", tokenExpiresAt: EXP }, + }); + }); +}); + +describe("whoop descriptor", () => { + it("treats a connection without a WHOOP user id as no grant", async () => { + const { db } = fakeDb({ + whoopUserId: null, + accessToken: "a1", + refreshToken: "r1", + tokenExpiresAt: EXP, + }); + await expect(whoopTokenDescriptor.readGrant(db, "u")).resolves.toBeNull(); + }); +}); + +const USER_COLUMNS: Array<[string, AnyDescriptor, string]> = [ + ["oura", ouraTokenDescriptor as unknown as AnyDescriptor, "oura"], + ["strava", stravaTokenDescriptor as unknown as AnyDescriptor, "strava"], +]; + +describe.each(USER_COLUMNS)("%s descriptor", (_name, d, prefix) => { + it("reads the User columns, with no expiry", async () => { + const { db } = fakeDb({ + [`${prefix}AccessTokenEncrypted`]: "a1", + [`${prefix}RefreshTokenEncrypted`]: "r1", + }); + await expect(d.readGrant(db, "u")).resolves.toEqual({ + accessCiphertext: "a1", + refreshCiphertext: "r1", + expiresAt: null, + }); + expect(d.tracksExpiry).toBe(false); + }); + + it("reads no grant when either token is missing", async () => { + const { db } = fakeDb({ [`${prefix}AccessTokenEncrypted`]: "a1" }); + await expect(d.readGrant(db, "u")).resolves.toBeNull(); + }); + + it("writes only over the refresh token it spent", async () => { + const { db, model } = fakeDb(null); + await d.writeGrant(db, "u", "r1", NEXT); + expect(model.updateMany).toHaveBeenCalledWith({ + where: { id: "u", [`${prefix}RefreshTokenEncrypted`]: "r1" }, + data: { + [`${prefix}AccessTokenEncrypted`]: "a2", + [`${prefix}RefreshTokenEncrypted`]: "r2", + }, + }); + }); +}); + +describe("polar descriptor", () => { + it("reads a token with nothing to refresh", async () => { + const { db } = fakeDb({ polarAccessTokenEncrypted: "a1" }); + await expect(polarTokenDescriptor.readGrant(db, "u")).resolves.toEqual({ + accessCiphertext: "a1", + refreshCiphertext: null, + expiresAt: null, + }); + expect(polarTokenDescriptor.refresh).toBeNull(); + }); +});