Files
VolcanicSecretary/src/telegram/google-device-flow.test.ts
2026-09-23 09:59:11 +00:00

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/);
});
});