mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
test(integrations): freeze that every OAuth provider refreshes through the kit
This commit is contained in:
1 parent
f9ed07551f
commit
b60fa0123b
2 files changed
+262
No files matched your search
@@ -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/<provider>/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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | 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<never>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user