396 lines · 13.0 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Fidelity tests for storeOAuthTokens + withRefreshedAccessToken.// Uses an in-memory secrets adapter so we can assert exactly which calls// happen in which order across the "no refresh needed", "refresh needed",// "refresh rotates the refresh_token", and "secret resolution fails" paths.// --------------------------------------------------------------------------- import { afterEach, describe, expect, it, vi } from "vitest";import { Effect, Exit } from "effect"; import {  OAUTH2_REFRESH_SKEW_MS,  storeOAuthTokens,  withRefreshedAccessToken,  type OAuth2AuthRefs,  type OAuth2SecretsIO,  type RefreshedAuthSnapshot,} from "./index"; // ---------------------------------------------------------------------------// In-memory secrets adapter// --------------------------------------------------------------------------- const makeSecrets = (initial: Record<string, string>): {  io: OAuth2SecretsIO;  state: Map<string, { value: string; name: string; purpose: string }>;  missing: Set<string>;} => {  const state = new Map<string, { value: string; name: string; purpose: string }>();  for (const [id, v] of Object.entries(initial))    state.set(id, { value: v, name: "", purpose: "" });  const missing = new Set<string>();  const io: OAuth2SecretsIO = {    resolve: (secretId) =>      Effect.gen(function* () {        if (missing.has(secretId))          return yield* Effect.fail(new Error(`secret ${secretId} not found`));        const entry = state.get(secretId);        if (!entry)          return yield* Effect.fail(new Error(`secret ${secretId} not found`));        return entry.value;      }),    setValue: ({ secretId, value, name, purpose }) =>      Effect.sync(() => {        state.set(secretId, { value, name, purpose });      }),  };  return { io, state, missing };}; // ---------------------------------------------------------------------------// fetch capture for refresh path// --------------------------------------------------------------------------- const originalFetch = globalThis.fetch; const captureFetch = (  response: Response | (() => Response),): { calls: Array<{ url: string; init: RequestInit }> } => {  const calls: Array<{ url: string; init: RequestInit }> = [];  globalThis.fetch = vi    .fn()    .mockImplementation(async (url: string, init: RequestInit) => {      calls.push({ url, init });      return typeof response === "function" ? response() : response;    }) as unknown as typeof fetch;  return { calls };}; const jsonResponse = (status: number, body: unknown): Response =>  new Response(JSON.stringify(body), {    status,    headers: { "content-type": "application/json" },  }); afterEach(() => {  globalThis.fetch = originalFetch;}); // ---------------------------------------------------------------------------// storeOAuthTokens// --------------------------------------------------------------------------- describe("storeOAuthTokens", () => {  const baseInput = {    slug: "acme_api",    displayName: "Acme API",    accessTokenPurpose: "acme_oauth_access_token",    refreshTokenPurpose: "acme_oauth_refresh_token",  };   it("stores both access and refresh tokens as new secrets", async () => {    const calls: Array<{ idPrefix: string; name: string; purpose: string; value: string }> = [];    const result = await Effect.runPromise(      storeOAuthTokens({        ...baseInput,        tokens: {          access_token: "tok",          refresh_token: "rtok",          token_type: "Bearer",          expires_in: 3600,          scope: "read write",        },        createSecret: (input) => {          calls.push(input);          return Effect.succeed({ id: `id_${calls.length}` });        },      }),    );    expect(calls).toHaveLength(2);    expect(calls[0]!.idPrefix).toBe("acme_api_access_token");    expect(calls[0]!.name).toBe("Acme API Access Token");    expect(calls[0]!.purpose).toBe("acme_oauth_access_token");    expect(calls[0]!.value).toBe("tok");    expect(calls[1]!.idPrefix).toBe("acme_api_refresh_token");    expect(calls[1]!.value).toBe("rtok");    expect(result.accessTokenSecretId).toBe("id_1");    expect(result.refreshTokenSecretId).toBe("id_2");    expect(result.tokenType).toBe("Bearer");    expect(result.scope).toBe("read write");    expect(result.expiresAt).toBeGreaterThan(Date.now());  });   it("omits the refresh-token secret when the response did not return one", async () => {    let calls = 0;    const result = await Effect.runPromise(      storeOAuthTokens({        ...baseInput,        tokens: { access_token: "tok" },        createSecret: () => {          calls++;          return Effect.succeed({ id: `id_${calls}` });        },      }),    );    expect(calls).toBe(1);    expect(result.refreshTokenSecretId).toBeNull();    expect(result.tokenType).toBe("Bearer");    expect(result.expiresAt).toBeNull();    expect(result.scope).toBeNull();  });   it("wraps createSecret failure in an OAuth2Error with a descriptive message", async () => {    const exit = await Effect.runPromiseExit(      storeOAuthTokens({        ...baseInput,        tokens: { access_token: "tok", refresh_token: "rtok" },        createSecret: () => Effect.fail(new Error("kv write failed")),      }),    );    expect(Exit.isFailure(exit)).toBe(true);    if (Exit.isFailure(exit)) {      const json = JSON.stringify(exit.cause);      expect(json).toContain("OAuth2Error");      expect(json).toContain("Failed to store access token");      expect(json).toContain("kv write failed");    }  });}); // ---------------------------------------------------------------------------// withRefreshedAccessToken// --------------------------------------------------------------------------- describe("withRefreshedAccessToken", () => {  const makeAuth = (overrides: Partial<OAuth2AuthRefs> = {}): OAuth2AuthRefs => ({    clientIdSecretId: "cid-secret",    clientSecretSecretId: "csecret-secret",    accessTokenSecretId: "atok-secret",    refreshTokenSecretId: "rtok-secret",    tokenType: "Bearer",    expiresAt: null,    scopes: ["read", "write"],    ...overrides,  });   const baseInput = {    tokenUrl: "https://api.example.com/oauth/token",    displayName: "Acme",    accessTokenPurpose: "acme_access",    refreshTokenPurpose: "acme_refresh",  };   it("returns the current access token without refreshing when expiresAt is null", async () => {    const { io } = makeSecrets({      "atok-secret": "current-access-token",      "rtok-secret": "stored-refresh",      "cid-secret": "cid",      "csecret-secret": "csecret",    });    const fetchSpy = vi.fn();    globalThis.fetch = fetchSpy as unknown as typeof fetch;    const persistAuth = vi.fn(() => Effect.void);     const result = await Effect.runPromise(      withRefreshedAccessToken({        ...baseInput,        auth: makeAuth({ expiresAt: null }),        secrets: io,        persistAuth,      }),    );    expect(result).toBe("current-access-token");    expect(fetchSpy).not.toHaveBeenCalled();    expect(persistAuth).not.toHaveBeenCalled();  });   it("returns the current access token when expiresAt is comfortably in the future", async () => {    const { io } = makeSecrets({      "atok-secret": "current",      "rtok-secret": "r",      "cid-secret": "c",      "csecret-secret": "s",    });    const fetchSpy = vi.fn();    globalThis.fetch = fetchSpy as unknown as typeof fetch;     const result = await Effect.runPromise(      withRefreshedAccessToken({        ...baseInput,        auth: makeAuth({ expiresAt: Date.now() + 10 * 60_000 }),        secrets: io,        persistAuth: () => Effect.void,      }),    );    expect(result).toBe("current");    expect(fetchSpy).not.toHaveBeenCalled();  });   it("refreshes when expiresAt is within the skew window, persists new token, calls persistAuth", async () => {    const { io, state } = makeSecrets({      "atok-secret": "old-access",      "rtok-secret": "stored-refresh",      "cid-secret": "client-1",      "csecret-secret": "secret-1",    });    const { calls } = captureFetch(      jsonResponse(200, {        access_token: "new-access",        token_type: "Bearer",        expires_in: 3600,      }),    );    const persistedSnapshots: RefreshedAuthSnapshot[] = [];    const persistAuth = (snapshot: RefreshedAuthSnapshot) =>      Effect.sync(() => {        persistedSnapshots.push(snapshot);      });     const result = await Effect.runPromise(      withRefreshedAccessToken({        ...baseInput,        auth: makeAuth({ expiresAt: Date.now() + 30_000 }),        secrets: io,        persistAuth,      }),    );     expect(result).toBe("new-access");    expect(calls).toHaveLength(1);    const body = calls[0]!.init.body as URLSearchParams;    expect(body.get("grant_type")).toBe("refresh_token");    expect(body.get("refresh_token")).toBe("stored-refresh");    expect(body.get("client_id")).toBe("client-1");    expect(body.get("client_secret")).toBe("secret-1");    expect(body.get("scope")).toBe("read write");     // Access-token secret was updated in place with the new value.    expect(state.get("atok-secret")!.value).toBe("new-access");    // persistAuth called with updated snapshot.    expect(persistedSnapshots).toHaveLength(1);    expect(persistedSnapshots[0]!.tokenType).toBe("Bearer");    expect(persistedSnapshots[0]!.expiresAt).toBeGreaterThan(Date.now());  });   it("rotates the refresh_token secret when the server returns a new one", async () => {    const { io, state } = makeSecrets({      "atok-secret": "old",      "rtok-secret": "old-refresh",      "cid-secret": "c",      "csecret-secret": "s",    });    captureFetch(      jsonResponse(200, {        access_token: "new",        refresh_token: "new-refresh",        expires_in: 3600,      }),    );    await Effect.runPromise(      withRefreshedAccessToken({        ...baseInput,        auth: makeAuth({ expiresAt: Date.now() + 30_000 }),        secrets: io,        persistAuth: () => Effect.void,      }),    );    expect(state.get("rtok-secret")!.value).toBe("new-refresh");  });   it("does NOT touch the refresh_token secret when the server omits it", async () => {    const { io, state } = makeSecrets({      "atok-secret": "old",      "rtok-secret": "old-refresh",      "cid-secret": "c",      "csecret-secret": "s",    });    captureFetch(jsonResponse(200, { access_token: "new", expires_in: 3600 }));    await Effect.runPromise(      withRefreshedAccessToken({        ...baseInput,        auth: makeAuth({ expiresAt: Date.now() + 30_000 }),        secrets: io,        persistAuth: () => Effect.void,      }),    );    expect(state.get("rtok-secret")!.value).toBe("old-refresh");  });   it("skips the refresh path entirely when refreshTokenSecretId is null, even if expired", async () => {    const { io } = makeSecrets({      "atok-secret": "only-access",      "cid-secret": "c",      "csecret-secret": "s",    });    const fetchSpy = vi.fn();    globalThis.fetch = fetchSpy as unknown as typeof fetch;    const result = await Effect.runPromise(      withRefreshedAccessToken({        ...baseInput,        auth: makeAuth({          refreshTokenSecretId: null,          expiresAt: Date.now() - 10_000,        }),        secrets: io,        persistAuth: () => Effect.void,      }),    );    expect(result).toBe("only-access");    expect(fetchSpy).not.toHaveBeenCalled();  });   it("omits client_secret from the refresh call when clientSecretSecretId is null", async () => {    const { io } = makeSecrets({      "atok-secret": "old",      "rtok-secret": "rtok",      "cid-secret": "public-client",    });    const { calls } = captureFetch(      jsonResponse(200, { access_token: "new", expires_in: 3600 }),    );    await Effect.runPromise(      withRefreshedAccessToken({        ...baseInput,        auth: makeAuth({          clientSecretSecretId: null,          expiresAt: Date.now() + 30_000,        }),        secrets: io,        persistAuth: () => Effect.void,      }),    );    const body = calls[0]!.init.body as URLSearchParams;    expect(body.get("client_id")).toBe("public-client");    expect(body.has("client_secret")).toBe(false);  });   it("wraps secret-resolve failures in OAuth2Error with descriptive messages", async () => {    const { io, missing } = makeSecrets({      "atok-secret": "old",      "rtok-secret": "rtok",      "cid-secret": "c",      "csecret-secret": "s",    });    missing.add("rtok-secret");    captureFetch(jsonResponse(200, { access_token: "new" }));    const exit = await Effect.runPromiseExit(      withRefreshedAccessToken({        ...baseInput,        auth: makeAuth({ expiresAt: Date.now() + 30_000 }),        secrets: io,        persistAuth: () => Effect.void,      }),    );    expect(Exit.isFailure(exit)).toBe(true);    if (Exit.isFailure(exit)) {      const json = JSON.stringify(exit.cause);      expect(json).toContain("OAuth2Error");      expect(json).toContain("Failed to resolve OAuth refresh token");    }  });   it("uses the default 60s refresh skew window", () => {    expect(OAUTH2_REFRESH_SKEW_MS).toBe(60_000);  });});