115 lines
5.0 KiB
TypeScript
115 lines
5.0 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { createDeviceFlowClient } from "./google-device-flow.js";
|
|
|
|
type FetchCall = { url: string; init?: RequestInit };
|
|
|
|
function stubFetch(responses: Array<{ status?: number; body: unknown }>) {
|
|
const calls: FetchCall[] = [];
|
|
let i = 0;
|
|
const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
calls.push({ url, init });
|
|
const entry = responses[i++];
|
|
if (!entry) throw new Error(`unexpected fetch call: ${url}`);
|
|
const status = entry.status ?? 200;
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
text: async () => JSON.stringify(entry.body),
|
|
json: async () => entry.body,
|
|
} as Response;
|
|
});
|
|
return { fn, calls };
|
|
}
|
|
|
|
const opts = { clientId: "cid", clientSecret: "csecret" };
|
|
|
|
describe("createDeviceFlowClient.start", () => {
|
|
it("POSTs client_id and scope, parses the response, and computes expiresAt", async () => {
|
|
const { fn, calls } = stubFetch([
|
|
{
|
|
body: {
|
|
device_code: "dev-1",
|
|
user_code: "ABCD-EFGH",
|
|
verification_url: "https://google.com/device",
|
|
expires_in: 900,
|
|
interval: 5,
|
|
},
|
|
},
|
|
]);
|
|
const before = Date.now();
|
|
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
|
|
const code = await client.start();
|
|
const after = Date.now();
|
|
|
|
expect(calls[0].url).toBe("https://oauth2.googleapis.com/device/code");
|
|
expect(calls[0].init?.method).toBe("POST");
|
|
const body = new URLSearchParams(calls[0].init!.body as string);
|
|
expect(body.get("client_id")).toBe("cid");
|
|
expect(body.get("scope")).toBe("https://www.googleapis.com/auth/calendar");
|
|
|
|
expect(code.deviceCode).toBe("dev-1");
|
|
expect(code.userCode).toBe("ABCD-EFGH");
|
|
expect(code.verificationUrl).toBe("https://google.com/device");
|
|
expect(code.interval).toBe(5);
|
|
expect(code.expiresAt).toBeGreaterThanOrEqual(before + 900_000);
|
|
expect(code.expiresAt).toBeLessThanOrEqual(after + 900_000);
|
|
});
|
|
});
|
|
|
|
describe("createDeviceFlowClient.complete", () => {
|
|
it("returns success and extracts the refresh_token", async () => {
|
|
const { fn, calls } = stubFetch([
|
|
{ body: { access_token: "at", refresh_token: "rt", expires_in: 3600, token_type: "Bearer" } },
|
|
]);
|
|
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
|
|
const result = await client.complete("dev-1");
|
|
expect(result).toEqual({ kind: "success", refreshToken: "rt" });
|
|
const body = new URLSearchParams(calls[0].init!.body as string);
|
|
expect(body.get("client_id")).toBe("cid");
|
|
expect(body.get("client_secret")).toBe("csecret");
|
|
expect(body.get("device_code")).toBe("dev-1");
|
|
expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code");
|
|
});
|
|
|
|
it("returns pending for authorization_pending", async () => {
|
|
const { fn } = stubFetch([{ status: 400, body: { error: "authorization_pending" } }]);
|
|
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
|
|
expect(await client.complete("dev-1")).toEqual({ kind: "pending" });
|
|
});
|
|
|
|
it("returns slow_down for slow_down", async () => {
|
|
const { fn } = stubFetch([{ status: 400, body: { error: "slow_down" } }]);
|
|
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
|
|
expect(await client.complete("dev-1")).toEqual({ kind: "slow_down" });
|
|
});
|
|
|
|
it("returns denied for access_denied", async () => {
|
|
const { fn } = stubFetch([{ status: 400, body: { error: "access_denied" } }]);
|
|
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
|
|
expect(await client.complete("dev-1")).toEqual({ kind: "denied" });
|
|
});
|
|
|
|
it("returns expired for expired_token", async () => {
|
|
const { fn } = stubFetch([{ status: 400, body: { error: "expired_token" } }]);
|
|
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
|
|
expect(await client.complete("dev-1")).toEqual({ kind: "expired" });
|
|
});
|
|
|
|
it("returns error for unknown error codes and non-400 failures", async () => {
|
|
const { fn } = stubFetch([{ status: 400, body: { error: "internal_error", error_description: "boom" } }]);
|
|
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
|
|
const r = await client.complete("dev-1");
|
|
expect(r.kind).toBe("error");
|
|
if (r.kind === "error") expect(r.message).toMatch(/internal_error/);
|
|
});
|
|
|
|
it("returns error when the success body is missing refresh_token", async () => {
|
|
const { fn } = stubFetch([{ body: { access_token: "at", expires_in: 3600 } }]);
|
|
const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch });
|
|
const r = await client.complete("dev-1");
|
|
expect(r.kind).toBe("error");
|
|
if (r.kind === "error") expect(r.message).toMatch(/no refresh token/);
|
|
});
|
|
});
|