|
| 1 | +import { |
| 2 | + afterAll, |
| 3 | + afterEach, |
| 4 | + beforeEach, |
| 5 | + describe, |
| 6 | + expect, |
| 7 | + mock, |
| 8 | + spyOn, |
| 9 | + test, |
| 10 | +} from "bun:test"; |
| 11 | +import * as core from "@actions/core"; |
| 12 | +import type { RetryOptions } from "../base-action/src/retry"; |
| 13 | + |
| 14 | +// Drop-in replacement for retryWithBackoff with the same semantics but no |
| 15 | +// sleep between attempts, so the failure paths run instantly. Only |
| 16 | +// src/github/token.ts and an MCP server (never imported by other suites) |
| 17 | +// use this module, so the mock cannot leak meaningfully. |
| 18 | +mock.module("../src/utils/retry", () => ({ |
| 19 | + retryWithBackoff: async <T>( |
| 20 | + operation: () => Promise<T>, |
| 21 | + options: RetryOptions = {}, |
| 22 | + ): Promise<T> => { |
| 23 | + const { maxAttempts = 3, shouldRetry } = options; |
| 24 | + let lastError: Error | undefined; |
| 25 | + for (let attempt = 1; attempt <= maxAttempts; attempt++) { |
| 26 | + try { |
| 27 | + return await operation(); |
| 28 | + } catch (error) { |
| 29 | + lastError = error instanceof Error ? error : new Error(String(error)); |
| 30 | + if (shouldRetry && !shouldRetry(lastError)) { |
| 31 | + throw lastError; |
| 32 | + } |
| 33 | + } |
| 34 | + } |
| 35 | + throw lastError; |
| 36 | + }, |
| 37 | +})); |
| 38 | + |
| 39 | +import { |
| 40 | + parseAdditionalPermissions, |
| 41 | + setupGitHubToken, |
| 42 | + WorkflowValidationSkipError, |
| 43 | +} from "../src/github/token"; |
| 44 | + |
| 45 | +const EXCHANGE_URL = |
| 46 | + "https://api.anthropic.com/api/github/github-app-token-exchange"; |
| 47 | + |
| 48 | +const originalOverrideToken = process.env.OVERRIDE_GITHUB_TOKEN; |
| 49 | +const originalAdditionalPermissions = process.env.ADDITIONAL_PERMISSIONS; |
| 50 | + |
| 51 | +function jsonResponse(status: number, body: unknown): Response { |
| 52 | + return { |
| 53 | + ok: status >= 200 && status < 300, |
| 54 | + status, |
| 55 | + statusText: status === 200 ? "OK" : "Error", |
| 56 | + json: async () => body, |
| 57 | + } as unknown as Response; |
| 58 | +} |
| 59 | + |
| 60 | +let getIDTokenSpy: ReturnType<typeof spyOn<typeof core, "getIDToken">>; |
| 61 | +let fetchSpy: ReturnType<typeof spyOn<typeof globalThis, "fetch">>; |
| 62 | +let setSecretSpy: ReturnType<typeof spyOn<typeof core, "setSecret">>; |
| 63 | +let warningSpy: ReturnType<typeof spyOn<typeof core, "warning">>; |
| 64 | +let logSpy: ReturnType<typeof spyOn<typeof console, "log">>; |
| 65 | +let errorSpy: ReturnType<typeof spyOn<typeof console, "error">>; |
| 66 | + |
| 67 | +beforeEach(() => { |
| 68 | + delete process.env.OVERRIDE_GITHUB_TOKEN; |
| 69 | + delete process.env.ADDITIONAL_PERMISSIONS; |
| 70 | + |
| 71 | + getIDTokenSpy = spyOn(core, "getIDToken"); |
| 72 | + fetchSpy = spyOn(globalThis, "fetch"); |
| 73 | + setSecretSpy = spyOn(core, "setSecret").mockImplementation(() => {}); |
| 74 | + warningSpy = spyOn(core, "warning").mockImplementation(() => {}); |
| 75 | + logSpy = spyOn(console, "log").mockImplementation(() => {}); |
| 76 | + errorSpy = spyOn(console, "error").mockImplementation(() => {}); |
| 77 | +}); |
| 78 | + |
| 79 | +afterEach(() => { |
| 80 | + getIDTokenSpy.mockRestore(); |
| 81 | + fetchSpy.mockRestore(); |
| 82 | + setSecretSpy.mockRestore(); |
| 83 | + warningSpy.mockRestore(); |
| 84 | + logSpy.mockRestore(); |
| 85 | + errorSpy.mockRestore(); |
| 86 | +}); |
| 87 | + |
| 88 | +afterAll(() => { |
| 89 | + if (originalOverrideToken === undefined) { |
| 90 | + delete process.env.OVERRIDE_GITHUB_TOKEN; |
| 91 | + } else { |
| 92 | + process.env.OVERRIDE_GITHUB_TOKEN = originalOverrideToken; |
| 93 | + } |
| 94 | + if (originalAdditionalPermissions === undefined) { |
| 95 | + delete process.env.ADDITIONAL_PERMISSIONS; |
| 96 | + } else { |
| 97 | + process.env.ADDITIONAL_PERMISSIONS = originalAdditionalPermissions; |
| 98 | + } |
| 99 | +}); |
| 100 | + |
| 101 | +describe("setupGitHubToken", () => { |
| 102 | + test("returns the override token without touching OIDC", async () => { |
| 103 | + process.env.OVERRIDE_GITHUB_TOKEN = "ghp_override"; |
| 104 | + |
| 105 | + const token = await setupGitHubToken(); |
| 106 | + |
| 107 | + expect(token).toBe("ghp_override"); |
| 108 | + expect(getIDTokenSpy).not.toHaveBeenCalled(); |
| 109 | + expect(fetchSpy).not.toHaveBeenCalled(); |
| 110 | + expect(logSpy).toHaveBeenCalledWith( |
| 111 | + "Using provided GITHUB_TOKEN for authentication", |
| 112 | + ); |
| 113 | + }); |
| 114 | + |
| 115 | + test("exchanges the OIDC token for an app token", async () => { |
| 116 | + getIDTokenSpy.mockResolvedValue("oidc-123"); |
| 117 | + fetchSpy.mockResolvedValue(jsonResponse(200, { token: "app-tok" })); |
| 118 | + |
| 119 | + const token = await setupGitHubToken(); |
| 120 | + |
| 121 | + expect(token).toBe("app-tok"); |
| 122 | + expect(getIDTokenSpy).toHaveBeenCalledWith("claude-code-github-action"); |
| 123 | + expect(setSecretSpy).toHaveBeenCalledWith("app-tok"); |
| 124 | + |
| 125 | + const [url, options] = fetchSpy.mock.calls[0] as [string, RequestInit]; |
| 126 | + expect(url).toBe(EXCHANGE_URL); |
| 127 | + expect(options.method).toBe("POST"); |
| 128 | + expect((options.headers as Record<string, string>)["Authorization"]).toBe( |
| 129 | + "Bearer oidc-123", |
| 130 | + ); |
| 131 | + expect(options.body).toBeUndefined(); |
| 132 | + |
| 133 | + expect(logSpy).toHaveBeenCalledWith("Requesting OIDC token..."); |
| 134 | + expect(logSpy).toHaveBeenCalledWith("OIDC token successfully obtained"); |
| 135 | + expect(logSpy).toHaveBeenCalledWith( |
| 136 | + "Exchanging OIDC token for app token...", |
| 137 | + ); |
| 138 | + expect(logSpy).toHaveBeenCalledWith("App token successfully obtained"); |
| 139 | + expect(logSpy).toHaveBeenCalledWith("Using GITHUB_TOKEN from OIDC"); |
| 140 | + }); |
| 141 | + |
| 142 | + test("accepts the app_token response field as fallback", async () => { |
| 143 | + getIDTokenSpy.mockResolvedValue("oidc-123"); |
| 144 | + fetchSpy.mockResolvedValue(jsonResponse(200, { app_token: "alt-tok" })); |
| 145 | + |
| 146 | + const token = await setupGitHubToken(); |
| 147 | + |
| 148 | + expect(token).toBe("alt-tok"); |
| 149 | + }); |
| 150 | + |
| 151 | + test("sends merged permissions when ADDITIONAL_PERMISSIONS is set", async () => { |
| 152 | + process.env.ADDITIONAL_PERMISSIONS = "actions : read"; |
| 153 | + getIDTokenSpy.mockResolvedValue("oidc-123"); |
| 154 | + fetchSpy.mockResolvedValue(jsonResponse(200, { token: "app-tok" })); |
| 155 | + |
| 156 | + await setupGitHubToken(); |
| 157 | + |
| 158 | + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit]; |
| 159 | + expect((options.headers as Record<string, string>)["Content-Type"]).toBe( |
| 160 | + "application/json", |
| 161 | + ); |
| 162 | + expect(JSON.parse(options.body as string)).toEqual({ |
| 163 | + permissions: { |
| 164 | + contents: "write", |
| 165 | + pull_requests: "write", |
| 166 | + issues: "write", |
| 167 | + actions: "read", |
| 168 | + }, |
| 169 | + }); |
| 170 | + }); |
| 171 | + |
| 172 | + test("throws when the response carries no token", async () => { |
| 173 | + getIDTokenSpy.mockResolvedValue("oidc-123"); |
| 174 | + fetchSpy.mockResolvedValue(jsonResponse(200, {})); |
| 175 | + |
| 176 | + await expect(setupGitHubToken()).rejects.toThrow( |
| 177 | + "App token not found in response", |
| 178 | + ); |
| 179 | + expect(fetchSpy).toHaveBeenCalledTimes(3); |
| 180 | + }); |
| 181 | + |
| 182 | + test("fails with permissions guidance when OIDC token cannot be obtained", async () => { |
| 183 | + getIDTokenSpy.mockRejectedValue(new Error("no id token")); |
| 184 | + |
| 185 | + await expect(setupGitHubToken()).rejects.toThrow("id-token: write"); |
| 186 | + expect(getIDTokenSpy).toHaveBeenCalledTimes(3); |
| 187 | + expect(fetchSpy).not.toHaveBeenCalled(); |
| 188 | + expect(errorSpy).toHaveBeenCalledWith( |
| 189 | + "Failed to get OIDC token:", |
| 190 | + expect.any(Error), |
| 191 | + ); |
| 192 | + }); |
| 193 | + |
| 194 | + test("propagates the API error message on non-ok responses", async () => { |
| 195 | + getIDTokenSpy.mockResolvedValue("oidc-123"); |
| 196 | + fetchSpy.mockResolvedValue( |
| 197 | + jsonResponse(401, { error: { message: "Bad credentials" } }), |
| 198 | + ); |
| 199 | + |
| 200 | + await expect(setupGitHubToken()).rejects.toThrow("Bad credentials"); |
| 201 | + expect(fetchSpy).toHaveBeenCalledTimes(3); |
| 202 | + expect(errorSpy).toHaveBeenCalledWith( |
| 203 | + "App token exchange failed: 401 Error - Bad credentials", |
| 204 | + ); |
| 205 | + }); |
| 206 | + |
| 207 | + test("falls back to Unknown error when the error body is empty", async () => { |
| 208 | + getIDTokenSpy.mockResolvedValue("oidc-123"); |
| 209 | + fetchSpy.mockResolvedValue(jsonResponse(500, {})); |
| 210 | + |
| 211 | + await expect(setupGitHubToken()).rejects.toThrow("Unknown error"); |
| 212 | + expect(errorSpy).toHaveBeenCalledWith( |
| 213 | + "App token exchange failed: 500 Error - Unknown error", |
| 214 | + ); |
| 215 | + }); |
| 216 | + |
| 217 | + describe("workflow validation skip", () => { |
| 218 | + const skipBody = { |
| 219 | + message: "workflow file not on default branch", |
| 220 | + error: { |
| 221 | + message: "inner message", |
| 222 | + details: { error_code: "workflow_not_found_on_default_branch" }, |
| 223 | + }, |
| 224 | + }; |
| 225 | + |
| 226 | + test("throws WorkflowValidationSkipError without retrying", async () => { |
| 227 | + getIDTokenSpy.mockResolvedValue("oidc-123"); |
| 228 | + fetchSpy.mockResolvedValue(jsonResponse(400, skipBody)); |
| 229 | + |
| 230 | + const error = await setupGitHubToken().catch((e: Error) => e); |
| 231 | + |
| 232 | + expect(error).toBeInstanceOf(WorkflowValidationSkipError); |
| 233 | + expect((error as Error).name).toBe("WorkflowValidationSkipError"); |
| 234 | + expect(fetchSpy).toHaveBeenCalledTimes(1); |
| 235 | + expect(warningSpy).toHaveBeenCalledTimes(1); |
| 236 | + expect(warningSpy).toHaveBeenCalledWith( |
| 237 | + "Skipping action due to workflow validation: workflow file not on default branch", |
| 238 | + ); |
| 239 | + expect(logSpy).toHaveBeenCalledWith( |
| 240 | + "Action skipped due to workflow validation error. This is expected when adding Claude Code workflows to new repositories or on PRs with workflow changes. If you're seeing this, your workflow will begin working once you merge your PR.", |
| 241 | + ); |
| 242 | + }); |
| 243 | + |
| 244 | + test("uses the top-level message when present", async () => { |
| 245 | + getIDTokenSpy.mockResolvedValue("oidc-123"); |
| 246 | + fetchSpy.mockResolvedValue(jsonResponse(400, skipBody)); |
| 247 | + |
| 248 | + await expect(setupGitHubToken()).rejects.toThrow( |
| 249 | + "workflow file not on default branch", |
| 250 | + ); |
| 251 | + }); |
| 252 | + |
| 253 | + test("falls back to the inner error message", async () => { |
| 254 | + getIDTokenSpy.mockResolvedValue("oidc-123"); |
| 255 | + fetchSpy.mockResolvedValue( |
| 256 | + jsonResponse(400, { |
| 257 | + error: { |
| 258 | + message: "inner message", |
| 259 | + details: { error_code: "workflow_not_found_on_default_branch" }, |
| 260 | + }, |
| 261 | + }), |
| 262 | + ); |
| 263 | + |
| 264 | + await expect(setupGitHubToken()).rejects.toThrow("inner message"); |
| 265 | + }); |
| 266 | + |
| 267 | + test("falls back to a generic message when the body has none", async () => { |
| 268 | + getIDTokenSpy.mockResolvedValue("oidc-123"); |
| 269 | + fetchSpy.mockResolvedValue( |
| 270 | + jsonResponse(400, { |
| 271 | + error: { |
| 272 | + details: { error_code: "workflow_not_found_on_default_branch" }, |
| 273 | + }, |
| 274 | + }), |
| 275 | + ); |
| 276 | + |
| 277 | + await expect(setupGitHubToken()).rejects.toThrow( |
| 278 | + "Workflow validation failed", |
| 279 | + ); |
| 280 | + }); |
| 281 | + }); |
| 282 | +}); |
| 283 | + |
| 284 | +describe("parseAdditionalPermissions boundary cases", () => { |
| 285 | + test("line with an empty value is ignored", () => { |
| 286 | + process.env.ADDITIONAL_PERMISSIONS = "actions:"; |
| 287 | + |
| 288 | + expect(parseAdditionalPermissions()).toBeUndefined(); |
| 289 | + }); |
| 290 | + |
| 291 | + test("line with an empty key is ignored", () => { |
| 292 | + process.env.ADDITIONAL_PERMISSIONS = ": read"; |
| 293 | + |
| 294 | + expect(parseAdditionalPermissions()).toBeUndefined(); |
| 295 | + }); |
| 296 | + |
| 297 | + test("input with only invalid lines yields undefined", () => { |
| 298 | + process.env.ADDITIONAL_PERMISSIONS = "no colon here"; |
| 299 | + |
| 300 | + expect(parseAdditionalPermissions()).toBeUndefined(); |
| 301 | + }); |
| 302 | +}); |
0 commit comments