|
1 | 1 | // @ts-check |
2 | | -import { describe, it, expect, beforeEach, vi } from "vitest"; |
| 2 | +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; |
3 | 3 | import { createRequire } from "module"; |
| 4 | +import * as fs from "fs"; |
| 5 | +import * as path from "path"; |
| 6 | +import * as os from "os"; |
4 | 7 |
|
5 | 8 | const require = createRequire(import.meta.url); |
6 | 9 |
|
@@ -235,3 +238,183 @@ describe("create_pull_request - security: branch name sanitization", () => { |
235 | 238 | expect(normalizeBranchName("UPPERCASE")).toBe("uppercase"); |
236 | 239 | }); |
237 | 240 | }); |
| 241 | + |
| 242 | +// ────────────────────────────────────────────────────── |
| 243 | +// allowed-files strict allowlist |
| 244 | +// ────────────────────────────────────────────────────── |
| 245 | + |
| 246 | +describe("create_pull_request - allowed-files strict allowlist", () => { |
| 247 | + let tempDir; |
| 248 | + let originalEnv; |
| 249 | + |
| 250 | + beforeEach(() => { |
| 251 | + originalEnv = { ...process.env }; |
| 252 | + process.env.GH_AW_WORKFLOW_ID = "test-workflow"; |
| 253 | + process.env.GITHUB_REPOSITORY = "test-owner/test-repo"; |
| 254 | + process.env.GITHUB_BASE_REF = "main"; |
| 255 | + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-allowed-test-")); |
| 256 | + |
| 257 | + global.core = { |
| 258 | + info: vi.fn(), |
| 259 | + warning: vi.fn(), |
| 260 | + error: vi.fn(), |
| 261 | + debug: vi.fn(), |
| 262 | + setFailed: vi.fn(), |
| 263 | + setOutput: vi.fn(), |
| 264 | + startGroup: vi.fn(), |
| 265 | + endGroup: vi.fn(), |
| 266 | + summary: { |
| 267 | + addRaw: vi.fn().mockReturnThis(), |
| 268 | + write: vi.fn().mockResolvedValue(undefined), |
| 269 | + }, |
| 270 | + }; |
| 271 | + global.github = { |
| 272 | + rest: { |
| 273 | + pulls: { |
| 274 | + create: vi.fn().mockResolvedValue({ data: { number: 1, html_url: "https://github.com/test" } }), |
| 275 | + }, |
| 276 | + repos: { |
| 277 | + get: vi.fn().mockResolvedValue({ data: { default_branch: "main" } }), |
| 278 | + }, |
| 279 | + }, |
| 280 | + graphql: vi.fn(), |
| 281 | + }; |
| 282 | + global.context = { |
| 283 | + eventName: "workflow_dispatch", |
| 284 | + repo: { owner: "test-owner", repo: "test-repo" }, |
| 285 | + payload: {}, |
| 286 | + }; |
| 287 | + global.exec = { |
| 288 | + exec: vi.fn().mockResolvedValue(0), |
| 289 | + getExecOutput: vi.fn().mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }), |
| 290 | + }; |
| 291 | + |
| 292 | + // Clear module cache so globals are picked up fresh |
| 293 | + delete require.cache[require.resolve("./create_pull_request.cjs")]; |
| 294 | + }); |
| 295 | + |
| 296 | + afterEach(() => { |
| 297 | + for (const key of Object.keys(process.env)) { |
| 298 | + if (!(key in originalEnv)) { |
| 299 | + delete process.env[key]; |
| 300 | + } |
| 301 | + } |
| 302 | + Object.assign(process.env, originalEnv); |
| 303 | + |
| 304 | + if (tempDir && fs.existsSync(tempDir)) { |
| 305 | + fs.rmSync(tempDir, { recursive: true, force: true }); |
| 306 | + } |
| 307 | + |
| 308 | + delete global.core; |
| 309 | + delete global.github; |
| 310 | + delete global.context; |
| 311 | + delete global.exec; |
| 312 | + vi.clearAllMocks(); |
| 313 | + }); |
| 314 | + |
| 315 | + /** |
| 316 | + * Creates a minimal git patch touching the given file paths. |
| 317 | + */ |
| 318 | + function createPatchWithFiles(...filePaths) { |
| 319 | + const diffs = filePaths |
| 320 | + .map( |
| 321 | + p => `diff --git a/${p} b/${p} |
| 322 | +new file mode 100644 |
| 323 | +index 0000000..abc1234 |
| 324 | +--- /dev/null |
| 325 | ++++ b/${p} |
| 326 | +@@ -0,0 +1 @@ |
| 327 | ++content |
| 328 | +` |
| 329 | + ) |
| 330 | + .join("\n"); |
| 331 | + return `From abc123 Mon Sep 17 00:00:00 2001 |
| 332 | +From: Test Author <test@example.com> |
| 333 | +Date: Mon, 1 Jan 2024 00:00:00 +0000 |
| 334 | +Subject: [PATCH] Test commit |
| 335 | +
|
| 336 | +${diffs} |
| 337 | +-- |
| 338 | +2.34.1 |
| 339 | +`; |
| 340 | + } |
| 341 | + |
| 342 | + function writePatch(content) { |
| 343 | + const p = path.join(tempDir, "test.patch"); |
| 344 | + fs.writeFileSync(p, content); |
| 345 | + return p; |
| 346 | + } |
| 347 | + |
| 348 | + it("should reject files outside the allowed-files allowlist", async () => { |
| 349 | + const patchPath = writePatch(createPatchWithFiles("src/index.js")); |
| 350 | + |
| 351 | + const { main } = require("./create_pull_request.cjs"); |
| 352 | + const handler = await main({ allowed_files: [".github/aw/**"] }); |
| 353 | + const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); |
| 354 | + |
| 355 | + expect(result.success).toBe(false); |
| 356 | + expect(result.error).toContain("outside the allowed-files list"); |
| 357 | + expect(result.error).toContain("src/index.js"); |
| 358 | + }); |
| 359 | + |
| 360 | + it("should reject a mixed patch where some files are outside the allowlist", async () => { |
| 361 | + const patchPath = writePatch(createPatchWithFiles(".github/aw/github-agentic-workflows.md", "src/index.js")); |
| 362 | + |
| 363 | + const { main } = require("./create_pull_request.cjs"); |
| 364 | + const handler = await main({ allowed_files: [".github/aw/**"] }); |
| 365 | + const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); |
| 366 | + |
| 367 | + expect(result.success).toBe(false); |
| 368 | + expect(result.error).toContain("outside the allowed-files list"); |
| 369 | + expect(result.error).toContain("src/index.js"); |
| 370 | + expect(result.error).not.toContain(".github/aw/github-agentic-workflows.md"); |
| 371 | + }); |
| 372 | + |
| 373 | + it("should still enforce protected-files when allowed-files matches (orthogonal checks)", async () => { |
| 374 | + // allowed-files and protected-files are orthogonal: both checks must pass. |
| 375 | + // Matching the allowlist does NOT bypass the protected-files policy. |
| 376 | + const patchPath = writePatch(createPatchWithFiles(".github/aw/instructions.md")); |
| 377 | + |
| 378 | + const { main } = require("./create_pull_request.cjs"); |
| 379 | + const handler = await main({ |
| 380 | + allowed_files: [".github/aw/**"], |
| 381 | + protected_path_prefixes: [".github/"], |
| 382 | + protected_files_policy: "blocked", |
| 383 | + }); |
| 384 | + const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); |
| 385 | + |
| 386 | + expect(result.success).toBe(false); |
| 387 | + expect(result.error).toContain("protected files"); |
| 388 | + }); |
| 389 | + |
| 390 | + it("should allow a protected file when both allowed-files matches and protected-files: allowed is set", async () => { |
| 391 | + // Both checks are satisfied explicitly: allowlist scope + protected-files permission. |
| 392 | + const patchPath = writePatch(createPatchWithFiles(".github/aw/instructions.md")); |
| 393 | + |
| 394 | + const { main } = require("./create_pull_request.cjs"); |
| 395 | + const handler = await main({ |
| 396 | + allowed_files: [".github/aw/**"], |
| 397 | + protected_path_prefixes: [".github/"], |
| 398 | + protected_files_policy: "allowed", |
| 399 | + }); |
| 400 | + const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); |
| 401 | + |
| 402 | + // Should not be blocked by either check |
| 403 | + expect(result.error || "").not.toContain("protected files"); |
| 404 | + expect(result.error || "").not.toContain("outside the allowed-files list"); |
| 405 | + }); |
| 406 | + |
| 407 | + it("should still enforce protected-files when allowed-files is not set", async () => { |
| 408 | + const patchPath = writePatch(createPatchWithFiles(".github/aw/instructions.md")); |
| 409 | + |
| 410 | + const { main } = require("./create_pull_request.cjs"); |
| 411 | + const handler = await main({ |
| 412 | + protected_path_prefixes: [".github/"], |
| 413 | + protected_files_policy: "blocked", |
| 414 | + }); |
| 415 | + const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); |
| 416 | + |
| 417 | + expect(result.success).toBe(false); |
| 418 | + expect(result.error).toContain("protected files"); |
| 419 | + }); |
| 420 | +}); |
0 commit comments