diff --git a/extensions/github-copilot/login.test.ts b/extensions/github-copilot/login.test.ts new file mode 100644 index 000000000000..255a7ee92052 --- /dev/null +++ b/extensions/github-copilot/login.test.ts @@ -0,0 +1,205 @@ +// Github Copilot tests cover device-flow login behavior. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + runGitHubCopilotDeviceFlow, + setGitHubCopilotDeviceFlowFetchGuardForTesting, +} from "./login.js"; + +const DEVICE_CODE_URL = "https://github.com/login/device/code"; +const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; + +// A valid device code payload GitHub returns on the first step. +const VALID_DEVICE_CODE_BODY = { + device_code: "dev-code-abc123", + user_code: "ABCD-1234", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 5, +}; + +function guardResponse(body: unknown, status = 200, url = DEVICE_CODE_URL) { + return { + response: new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + finalUrl: url, + release: vi.fn(async () => {}), + }; +} + +afterEach(() => { + setGitHubCopilotDeviceFlowFetchGuardForTesting(null); + vi.restoreAllMocks(); +}); + +describe("runGitHubCopilotDeviceFlow — normal flow", () => { + it("returns authorized status and access token on successful flow", async () => { + let callIdx = 0; + setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => { + callIdx += 1; + if (callIdx === 1) { + expect(params.url).toBe(DEVICE_CODE_URL); + return guardResponse(VALID_DEVICE_CODE_BODY); + } + expect(params.url).toBe(ACCESS_TOKEN_URL); + return guardResponse( + { access_token: "ghu_tok_xyz", token_type: "bearer" }, + 200, + ACCESS_TOKEN_URL, + ); + }); + + const showCode = vi.fn(async () => {}); + const result = await runGitHubCopilotDeviceFlow({ showCode }); + + expect(result).toEqual({ status: "authorized", accessToken: "ghu_tok_xyz" }); + expect(showCode).toHaveBeenCalledWith({ + verificationUrl: "https://github.com/login/device", + userCode: "ABCD-1234", + expiresInMs: expect.any(Number), + }); + expect(callIdx).toBe(2); + }); + + it("returns access_denied when GitHub rejects the authorization", async () => { + let callIdx = 0; + setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => { + callIdx += 1; + if (callIdx === 1) { + return guardResponse(VALID_DEVICE_CODE_BODY); + } + return guardResponse({ error: "access_denied" }, 200, ACCESS_TOKEN_URL); + }); + + const result = await runGitHubCopilotDeviceFlow({ + showCode: vi.fn(async () => {}), + }); + expect(result).toEqual({ status: "access_denied" }); + }); + + it("returns expired when GitHub reports expired_token", async () => { + let callIdx = 0; + setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => { + callIdx += 1; + if (callIdx === 1) { + return guardResponse(VALID_DEVICE_CODE_BODY); + } + return guardResponse({ error: "expired_token" }, 200, ACCESS_TOKEN_URL); + }); + + const result = await runGitHubCopilotDeviceFlow({ + showCode: vi.fn(async () => {}), + }); + expect(result).toEqual({ status: "expired" }); + }); +}); + +describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => { + it("throws with failureLabel on non-OK device code response", async () => { + setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => guardResponse({}, 401)); + + await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn() })).rejects.toThrow( + "GitHub device code failed: HTTP 401", + ); + }); + + it("throws with failureLabel on non-OK access token response", async () => { + let callIdx = 0; + setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => { + callIdx += 1; + if (callIdx === 1) { + return guardResponse(VALID_DEVICE_CODE_BODY); + } + return guardResponse({}, 500, ACCESS_TOKEN_URL); + }); + + await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) })).rejects.toThrow( + "GitHub device token failed: HTTP 500", + ); + }); +}); + +describe("postGitHubDeviceFlowForm — response size bound", () => { + it("bounds oversized device code body and cancels the stream", async () => { + const chunk = new Uint8Array(1024 * 1024); // 1 MiB + let readCount = 0; + let canceled = false; + // 64 chunks × 1 MiB = 64 MiB — far exceeds the 16 MiB cap + const oversizedBody = new ReadableStream({ + pull(controller) { + if (readCount >= 64) { + controller.close(); + return; + } + readCount += 1; + controller.enqueue(chunk); + }, + cancel() { + canceled = true; + }, + }); + + setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({ + response: new Response(oversizedBody, { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + finalUrl: DEVICE_CODE_URL, + release: async () => {}, + })); + + await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn() })).rejects.toThrow( + "github-copilot.device-flow", + ); + + // Stream must be cancelled before all 64 MiB are consumed + expect(readCount).toBeLessThan(64); + expect(canceled).toBe(true); + }); + + it("bounds oversized access token body and cancels the stream", async () => { + const chunk = new Uint8Array(1024 * 1024); // 1 MiB + let readCount = 0; + let canceled = false; + let callIdx = 0; + + setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => { + callIdx += 1; + if (callIdx === 1) { + return guardResponse(VALID_DEVICE_CODE_BODY); + } + + const oversizedBody = new ReadableStream({ + pull(controller) { + if (readCount >= 64) { + controller.close(); + return; + } + readCount += 1; + controller.enqueue(chunk); + }, + cancel() { + canceled = true; + }, + }); + + return { + response: new Response(oversizedBody, { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + finalUrl: ACCESS_TOKEN_URL, + release: async () => {}, + }; + }); + + await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) })).rejects.toThrow( + "github-copilot.device-flow", + ); + + // Stream must be cancelled before all 64 MiB are consumed + expect(readCount).toBeLessThan(64); + expect(canceled).toBe(true); + }); +}); diff --git a/extensions/github-copilot/login.ts b/extensions/github-copilot/login.ts index fa73ebe60687..413d62bdaaa3 100644 --- a/extensions/github-copilot/login.ts +++ b/extensions/github-copilot/login.ts @@ -13,6 +13,7 @@ import { ensureAuthProfileStore, upsertAuthProfileWithLock, } from "openclaw/plugin-sdk/provider-auth"; +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; @@ -147,7 +148,9 @@ async function postGitHubDeviceFlowForm(params: { if (!response.ok) { throw new Error(`${params.failureLabel}: HTTP ${response.status}`); } - return parseJsonResponse(await response.json()); + return parseJsonResponse( + await readProviderJsonResponse(response, "github-copilot.device-flow"), + ); } finally { await release(); }