diff --git a/.github/workflows/sync-actions.yml b/.github/workflows/sync-actions.yml index 9eb2e5d..0b3a4a1 100644 --- a/.github/workflows/sync-actions.yml +++ b/.github/workflows/sync-actions.yml @@ -221,11 +221,22 @@ jobs: echo "::group::Syncing action folders from gh-aw/actions/ to repo root" # --archive preserves permissions/timestamps; --delete removes files absent in source (remote wins) # --exclude='README*' preserves any local README files in each destination folder + # --exclude='*.test.cjs' prevents test files from being copied into the destination for dir in gh-aw/actions/*/; do name=$(basename "$dir") echo "Syncing: gh-aw/actions/$name/ -> gh-aw-actions/$name/" - rsync --archive --verbose --delete --exclude='README*' "gh-aw/actions/$name/" "gh-aw-actions/$name/" + rsync --archive --verbose --delete --exclude='README*' --exclude='*.test.cjs' "gh-aw/actions/$name/" "gh-aw-actions/$name/" done + + # Explicitly remove any lingering *.test.cjs files from destination folders, since rsync --delete + # does not delete files that match an --exclude pattern. + for dir in gh-aw/actions/*/; do + name=$(basename "$dir") + if [ -d "gh-aw-actions/$name" ]; then + find "gh-aw-actions/$name" -type f -name '*.test.cjs' -delete 2>/dev/null || true + fi + done + echo "::endgroup::" - name: Log destination after sync diff --git a/setup/js/add_comment.test.cjs b/setup/js/add_comment.test.cjs deleted file mode 100644 index 54061e4..0000000 --- a/setup/js/add_comment.test.cjs +++ /dev/null @@ -1,1828 +0,0 @@ -// @ts-check -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("add_comment", () => { - let mockCore; - let mockGithub; - let mockContext; - let originalGlobals; - - beforeEach(() => { - // Save original globals - originalGlobals = { - core: global.core, - github: global.github, - context: global.context, - }; - - // Setup mock core - mockCore = { - info: () => {}, - warning: () => {}, - error: () => {}, - setOutput: () => {}, - setFailed: () => {}, - }; - - // Setup mock github API - mockGithub = { - rest: { - issues: { - createComment: async () => ({ - data: { - id: 12345, - html_url: "https://github.com/owner/repo/issues/42#issuecomment-12345", - }, - }), - listComments: async () => ({ data: [] }), - }, - }, - graphql: async () => ({ - repository: { - discussion: { - id: "D_kwDOTest123", - url: "https://github.com/owner/repo/discussions/10", - }, - }, - addDiscussionComment: { - comment: { - id: "DC_kwDOTest456", - url: "https://github.com/owner/repo/discussions/10#discussioncomment-456", - }, - }, - }), - }; - - // Setup mock context - mockContext = { - eventName: "pull_request", - runId: 12345, - repo: { - owner: "owner", - repo: "repo", - }, - payload: { - pull_request: { - number: 8535, // The correct PR that triggered the workflow - }, - }, - }; - - // Set globals - global.core = mockCore; - global.github = mockGithub; - global.context = mockContext; - }); - - afterEach(() => { - // Restore original globals - global.core = originalGlobals.core; - global.github = originalGlobals.github; - global.context = originalGlobals.context; - }); - - describe("target configuration", () => { - it("should use triggering PR context when target is 'triggering'", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let capturedIssueNumber = null; - mockGithub.rest.issues.createComment = async params => { - capturedIssueNumber = params.issue_number; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - // Execute the handler factory with target: "triggering" - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); - - const message = { - type: "add_comment", - body: "Test comment on triggering PR", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(capturedIssueNumber).toBe(8535); - expect(result.itemNumber).toBe(8535); - }); - - it("should use explicit PR number when target is a number", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let capturedIssueNumber = null; - mockGithub.rest.issues.createComment = async params => { - capturedIssueNumber = params.issue_number; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - // Execute the handler factory with target: 21 (explicit PR number) - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: '21' }); })()`); - - const message = { - type: "add_comment", - body: "Test comment on explicit PR", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(capturedIssueNumber).toBe(21); - expect(result.itemNumber).toBe(21); - }); - - it("should use item_number from message when target is '*'", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let capturedIssueNumber = null; - mockGithub.rest.issues.createComment = async params => { - capturedIssueNumber = params.issue_number; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - // Execute the handler factory with target: "*" - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: '*' }); })()`); - - const message = { - type: "add_comment", - item_number: 999, - body: "Test comment on item_number PR", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(capturedIssueNumber).toBe(999); - expect(result.itemNumber).toBe(999); - }); - - it("should fail when target is '*' but no item_number provided", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: '*' }); })()`); - - const message = { - type: "add_comment", - body: "Test comment without item_number", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(false); - expect(result.error).toMatch(/no.*item_number/i); - }); - - it("should use explicit item_number even with triggering target", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let capturedIssueNumber = null; - mockGithub.rest.issues.createComment = async params => { - capturedIssueNumber = params.issue_number; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - // Execute the handler factory with target: "triggering" (default) - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); - - const message = { - type: "add_comment", - item_number: 777, - body: "Test comment with explicit item_number", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(capturedIssueNumber).toBe(777); - expect(result.itemNumber).toBe(777); - }); - - it("should resolve from context when item_number is not provided", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let capturedIssueNumber = null; - mockGithub.rest.issues.createComment = async params => { - capturedIssueNumber = params.issue_number; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - // Execute the handler factory with target: "triggering" (default) - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); - - const message = { - type: "add_comment", - body: "Test comment without item_number, should use PR from context", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(capturedIssueNumber).toBe(8535); // Should use PR number from mockContext - expect(result.itemNumber).toBe(8535); - }); - - it("should use issue context when triggered by an issue", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - // Change context to issue - mockContext.eventName = "issues"; - mockContext.payload = { - issue: { - number: 42, - }, - }; - - let capturedIssueNumber = null; - mockGithub.rest.issues.createComment = async params => { - capturedIssueNumber = params.issue_number; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); - - const message = { - type: "add_comment", - body: "Test comment on issue", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(capturedIssueNumber).toBe(42); - expect(result.itemNumber).toBe(42); - expect(result.isDiscussion).toBe(false); - }); - }); - - describe("discussion support", () => { - it("should use discussion context when triggered by a discussion", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - // Change context to discussion - mockContext.eventName = "discussion"; - mockContext.payload = { - discussion: { - number: 10, - }, - }; - - let capturedDiscussionNumber = null; - let graphqlCallCount = 0; - mockGithub.graphql = async (query, variables) => { - graphqlCallCount++; - if (query.includes("addDiscussionComment")) { - return { - addDiscussionComment: { - comment: { - id: "DC_kwDOTest456", - url: "https://github.com/owner/repo/discussions/10#discussioncomment-456", - }, - }, - }; - } - // Query for discussion ID - if (variables.number) { - capturedDiscussionNumber = variables.number; - } - if (variables.num) { - capturedDiscussionNumber = variables.num; - } - return { - repository: { - discussion: { - id: "D_kwDOTest123", - url: "https://github.com/owner/repo/discussions/10", - }, - }, - }; - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); - - const message = { - type: "add_comment", - body: "Test comment on discussion", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(capturedDiscussionNumber).toBe(10); - expect(result.itemNumber).toBe(10); - expect(result.isDiscussion).toBe(true); - }); - }); - - describe("regression test for wrong PR bug", () => { - it("should NOT comment on a different PR when workflow runs on PR #8535", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - // Simulate the exact scenario from the bug: - // - Workflow runs on PR #8535 (branch: copilot/enable-sandbox-mcp-gateway) - // - Should comment on PR #8535, NOT PR #21 - mockContext.eventName = "pull_request"; - mockContext.payload = { - pull_request: { - number: 8535, - }, - }; - - let capturedIssueNumber = null; - mockGithub.rest.issues.createComment = async params => { - capturedIssueNumber = params.issue_number; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - // Use default target configuration (should be "triggering") - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - body: "## Smoke Test: Copilot MCP Scripts\n\n✅ Test passed", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(capturedIssueNumber).toBe(8535); - expect(result.itemNumber).toBe(8535); - expect(capturedIssueNumber).not.toBe(21); - }); - }); - - describe("append-only-comments integration", () => { - it("should not hide older comments when append-only-comments is enabled", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - // Set up environment variable for append-only-comments - process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ - appendOnlyComments: true, - }); - process.env.GH_AW_WORKFLOW_ID = "test-workflow"; - - let hideCommentsWasCalled = false; - let listCommentsCalls = 0; - - mockGithub.rest.issues.listComments = async () => { - listCommentsCalls++; - return { - data: [ - { - id: 999, - node_id: "IC_kwDOTest999", - body: "Old comment ", - }, - ], - }; - }; - - mockGithub.graphql = async (query, variables) => { - if (query.includes("minimizeComment")) { - hideCommentsWasCalled = true; - } - return { - minimizeComment: { - minimizedComment: { - isMinimized: true, - }, - }, - }; - }; - - let capturedComment = null; - mockGithub.rest.issues.createComment = async params => { - capturedComment = params; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - // Execute with hide-older-comments enabled - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ hide_older_comments: true }); })()`); - - const message = { - type: "add_comment", - body: "New comment - should not hide old ones", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(hideCommentsWasCalled).toBe(false); - expect(listCommentsCalls).toBe(0); - expect(capturedComment).toBeTruthy(); - expect(capturedComment.body).toContain("New comment - should not hide old ones"); - - // Clean up - delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - delete process.env.GH_AW_WORKFLOW_ID; - }); - - it("should hide older comments when append-only-comments is not enabled", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - // Set up environment variable WITHOUT append-only-comments - delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - process.env.GH_AW_WORKFLOW_ID = "test-workflow"; - - let hideCommentsWasCalled = false; - let listCommentsCalls = 0; - - mockGithub.rest.issues.listComments = async () => { - listCommentsCalls++; - return { - data: [ - { - id: 999, - node_id: "IC_kwDOTest999", - body: "Old comment ", - }, - ], - }; - }; - - mockGithub.graphql = async (query, variables) => { - if (query.includes("minimizeComment")) { - hideCommentsWasCalled = true; - } - return { - minimizeComment: { - minimizedComment: { - isMinimized: true, - }, - }, - }; - }; - - let capturedComment = null; - mockGithub.rest.issues.createComment = async params => { - capturedComment = params; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - // Execute with hide-older-comments enabled - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ hide_older_comments: true }); })()`); - - const message = { - type: "add_comment", - body: "New comment - should hide old ones", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(hideCommentsWasCalled).toBe(true); - expect(listCommentsCalls).toBeGreaterThan(0); - expect(capturedComment).toBeTruthy(); - expect(capturedComment.body).toContain("New comment - should hide old ones"); - - // Clean up - delete process.env.GH_AW_WORKFLOW_ID; - }); - - it("should hide older comments with combined XML marker format (workflow_id inside gh-aw-agentic-workflow)", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - process.env.GH_AW_WORKFLOW_ID = "test-workflow"; - - let hideCommentsWasCalled = false; - let listCommentsCalls = 0; - - mockGithub.rest.issues.listComments = async () => { - listCommentsCalls++; - return { - data: [ - { - id: 999, - node_id: "IC_kwDOTest999", - body: "Old comment\n\n", - }, - ], - }; - }; - - mockGithub.graphql = async (query, variables) => { - if (query.includes("minimizeComment")) { - hideCommentsWasCalled = true; - } - return { - minimizeComment: { - minimizedComment: { - isMinimized: true, - }, - }, - }; - }; - - let capturedComment = null; - mockGithub.rest.issues.createComment = async params => { - capturedComment = params; - return { - data: { - id: 12346, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12346`, - }, - }; - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ hide_older_comments: true }); })()`); - - const message = { - type: "add_comment", - body: "New comment - should hide combined-marker old ones", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(hideCommentsWasCalled).toBe(true); - expect(listCommentsCalls).toBeGreaterThan(0); - expect(capturedComment).toBeTruthy(); - expect(capturedComment.body).toContain("New comment - should hide combined-marker old ones"); - - // Clean up - delete process.env.GH_AW_WORKFLOW_ID; - }); - }); - - describe("404 error handling", () => { - it("should treat 404 errors as warnings for issue comments", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let warningCalls = []; - mockCore.warning = msg => { - warningCalls.push(msg); - }; - - let errorCalls = []; - mockCore.error = msg => { - errorCalls.push(msg); - }; - - // Mock API to throw 404 error - mockGithub.rest.issues.createComment = async () => { - const error = new Error("Not Found"); - // @ts-ignore - error.status = 404; - throw error; - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - body: "Test comment", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(result.warning).toBeTruthy(); - expect(result.warning).toContain("not found"); - expect(result.skipped).toBe(true); - expect(warningCalls.length).toBeGreaterThan(0); - expect(warningCalls[0]).toContain("not found"); - expect(errorCalls.length).toBe(0); - }); - - it("should treat 404 errors as warnings for discussion comments", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let warningCalls = []; - mockCore.warning = msg => { - warningCalls.push(msg); - }; - - let errorCalls = []; - mockCore.error = msg => { - errorCalls.push(msg); - }; - - // Change context to discussion - mockContext.eventName = "discussion"; - mockContext.payload = { - discussion: { - number: 10, - }, - }; - - // Mock API to throw 404 error when querying discussion - mockGithub.graphql = async (query, variables) => { - if (query.includes("discussion(number")) { - // Return null to trigger the "not found" error - return { - repository: { - discussion: null, // Discussion not found - }, - }; - } - throw new Error("Unexpected query"); - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - body: "Test comment on deleted discussion", - }; - - const result = await handler(message, {}); - - // The error message contains "not found" so it should be treated as a warning - expect(result.success).toBe(true); - expect(result.warning).toBeTruthy(); - expect(result.warning).toContain("not found"); - expect(result.skipped).toBe(true); - expect(warningCalls.length).toBeGreaterThan(0); - expect(errorCalls.length).toBe(0); - }); - - it("should detect 404 from error message containing '404'", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let warningCalls = []; - mockCore.warning = msg => { - warningCalls.push(msg); - }; - - // Mock API to throw error with 404 in message - mockGithub.rest.issues.createComment = async () => { - throw new Error("API request failed with status 404"); - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - body: "Test comment", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(result.warning).toBeTruthy(); - expect(result.skipped).toBe(true); - expect(warningCalls.length).toBeGreaterThan(0); - }); - - it("should detect 404 from error message containing 'Not Found'", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let warningCalls = []; - mockCore.warning = msg => { - warningCalls.push(msg); - }; - - // Mock API to throw error with "Not Found" in message - mockGithub.rest.issues.createComment = async () => { - throw new Error("Resource Not Found"); - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - body: "Test comment", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(result.warning).toBeTruthy(); - expect(result.skipped).toBe(true); - expect(warningCalls.length).toBeGreaterThan(0); - }); - - it("should still fail for non-404 errors", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let warningCalls = []; - mockCore.warning = msg => { - warningCalls.push(msg); - }; - - let errorCalls = []; - mockCore.error = msg => { - errorCalls.push(msg); - }; - - // Mock API to throw non-404 error - mockGithub.rest.issues.createComment = async () => { - const error = new Error("Forbidden"); - // @ts-ignore - error.status = 403; - throw error; - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - body: "Test comment", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(false); - expect(result.error).toBeTruthy(); - expect(result.error).toContain("Forbidden"); - expect(errorCalls.length).toBeGreaterThan(0); - expect(errorCalls[0]).toContain("Failed to add comment"); - }); - - it("should still fail for validation errors", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let errorCalls = []; - mockCore.error = msg => { - errorCalls.push(msg); - }; - - // Mock API to throw validation error - mockGithub.rest.issues.createComment = async () => { - const error = new Error("Validation Failed"); - // @ts-ignore - error.status = 422; - throw error; - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - body: "Test comment", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(false); - expect(result.error).toBeTruthy(); - expect(result.error).toContain("Validation Failed"); - expect(errorCalls.length).toBeGreaterThan(0); - }); - }); - - describe("discussion fallback", () => { - it("should retry as discussion when item_number returns 404 as issue/PR", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let infoCalls = []; - mockCore.info = msg => { - infoCalls.push(msg); - }; - - // Mock REST API to return 404 (not found as issue/PR) - mockGithub.rest.issues.createComment = async () => { - const error = new Error("Not Found"); - // @ts-ignore - error.status = 404; - throw error; - }; - - // Mock GraphQL to return discussion - let graphqlCalls = []; - mockGithub.graphql = async (query, vars) => { - graphqlCalls.push({ query, vars }); - - // First call is to check if discussion exists - if (query.includes("query") && query.includes("discussion(number:")) { - return { - repository: { - discussion: { - id: "D_kwDOTest789", - url: "https://github.com/owner/repo/discussions/14117", - }, - }, - }; - } - - // Second call is to add comment - if (query.includes("mutation") && query.includes("addDiscussionComment")) { - return { - addDiscussionComment: { - comment: { - id: "DC_kwDOTest999", - body: "Test comment", - createdAt: "2026-02-06T12:00:00Z", - url: "https://github.com/owner/repo/discussions/14117#discussioncomment-999", - }, - }, - }; - } - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); - - const message = { - type: "add_comment", - item_number: 14117, - body: "Test comment on discussion", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(result.isDiscussion).toBe(true); - expect(result.itemNumber).toBe(14117); - expect(result.url).toContain("discussions/14117"); - - // Verify it logged the retry - const retryLog = infoCalls.find(msg => msg.includes("retrying as discussion")); - expect(retryLog).toBeTruthy(); - - const createdLog = infoCalls.find(msg => msg.includes("Created comment on discussion")); - expect(createdLog).toBeTruthy(); - }); - - it("should return skipped when item_number not found as issue/PR or discussion", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let warningCalls = []; - mockCore.warning = msg => { - warningCalls.push(msg); - }; - - // Mock REST API to return 404 - mockGithub.rest.issues.createComment = async () => { - const error = new Error("Not Found"); - // @ts-ignore - error.status = 404; - throw error; - }; - - // Mock GraphQL to also return 404 (discussion doesn't exist either) - mockGithub.graphql = async (query, vars) => { - if (query.includes("query") && query.includes("discussion(number:")) { - return { - repository: { - discussion: null, - }, - }; - } - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); - - const message = { - type: "add_comment", - item_number: 99999, - body: "Test comment", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(result.skipped).toBe(true); - expect(result.warning).toContain("not found"); - - // Verify warning was logged - const notFoundWarning = warningCalls.find(msg => msg.includes("not found")); - expect(notFoundWarning).toBeTruthy(); - }); - - it("should not retry as discussion when 404 occurs without explicit item_number", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let warningCalls = []; - mockCore.warning = msg => { - warningCalls.push(msg); - }; - - // Mock REST API to return 404 - mockGithub.rest.issues.createComment = async () => { - const error = new Error("Not Found"); - // @ts-ignore - error.status = 404; - throw error; - }; - - // GraphQL should not be called - let graphqlCalled = false; - mockGithub.graphql = async () => { - graphqlCalled = true; - throw new Error("GraphQL should not be called"); - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); - - const message = { - type: "add_comment", - // No item_number - using target resolution - body: "Test comment", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(result.skipped).toBe(true); - expect(graphqlCalled).toBe(false); - - // Verify warning was logged - const notFoundWarning = warningCalls.find(msg => msg.includes("not found")); - expect(notFoundWarning).toBeTruthy(); - }); - - it("should not retry as discussion when already detected as discussion context", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - // Set discussion context - mockContext.eventName = "discussion"; - mockContext.payload = { - discussion: { - number: 100, - }, - }; - - let warningCalls = []; - mockCore.warning = msg => { - warningCalls.push(msg); - }; - - // Mock GraphQL to return 404 for discussion - let graphqlCallCount = 0; - mockGithub.graphql = async (query, vars) => { - graphqlCallCount++; - - if (query.includes("query") && query.includes("discussion(number:")) { - return { - repository: { - discussion: null, - }, - }; - } - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({ target: 'triggering' }); })()`); - - const message = { - type: "add_comment", - body: "Test comment", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(result.skipped).toBe(true); - - // Should only call GraphQL once (not retry) - expect(graphqlCallCount).toBe(1); - }); - }); - - describe("temporary ID resolution", () => { - it("should resolve temporary ID in item_number field", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let capturedIssueNumber = null; - mockGithub.rest.issues.createComment = async params => { - capturedIssueNumber = params.issue_number; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - item_number: "aw_test01", - body: "Comment on issue created with temporary ID", - }; - - // Provide resolved temporary ID - const resolvedTemporaryIds = { - aw_test01: { repo: "owner/repo", number: 42 }, - }; - - const result = await handler(message, resolvedTemporaryIds); - - expect(result.success).toBe(true); - expect(capturedIssueNumber).toBe(42); - expect(result.itemNumber).toBe(42); - }); - - it("should defer when temporary ID is not yet resolved", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - item_number: "aw_test99", - body: "Comment on issue with unresolved temporary ID", - }; - - // Empty resolved map - temporary ID not yet resolved - const resolvedTemporaryIds = {}; - - const result = await handler(message, resolvedTemporaryIds); - - expect(result.success).toBe(false); - expect(result.deferred).toBe(true); - expect(result.error).toContain("aw_test99"); - }); - - it("should handle temporary ID with hash prefix", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let capturedIssueNumber = null; - mockGithub.rest.issues.createComment = async params => { - capturedIssueNumber = params.issue_number; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - item_number: "#aw_test02", - body: "Comment with hash prefix", - }; - - // Provide resolved temporary ID - const resolvedTemporaryIds = { - aw_test02: { repo: "owner/repo", number: 100 }, - }; - - const result = await handler(message, resolvedTemporaryIds); - - expect(result.success).toBe(true); - expect(capturedIssueNumber).toBe(100); - }); - - it("should handle invalid temporary ID format", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - item_number: "aw_", // Invalid: too short - body: "Comment with invalid temporary ID", - }; - - const resolvedTemporaryIds = {}; - - const result = await handler(message, resolvedTemporaryIds); - - expect(result.success).toBe(false); - expect(result.error).toContain("Invalid item_number specified"); - }); - - it("should replace temporary IDs in comment body", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - let capturedBody = null; - mockGithub.rest.issues.createComment = async params => { - capturedBody = params.body; - return { - data: { - id: 12345, - html_url: `https://github.com/owner/repo/issues/${params.issue_number}#issuecomment-12345`, - }, - }; - }; - - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - item_number: 42, - body: "References: #aw_test01 and #aw_test02", - }; - - // Provide resolved temporary IDs - const resolvedTemporaryIds = { - aw_test01: { repo: "owner/repo", number: 100 }, - aw_test02: { repo: "owner/repo", number: 200 }, - }; - - const result = await handler(message, resolvedTemporaryIds); - - expect(result.success).toBe(true); - expect(capturedBody).toContain("#100"); - expect(capturedBody).toContain("#200"); - expect(capturedBody).not.toContain("aw_test01"); - expect(capturedBody).not.toContain("aw_test02"); - }); - }); - - describe("sanitization preserves markers", () => { - it("should preserve tracker ID markers after sanitization", async () => { - const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); - - // Setup environment - process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; - process.env.GH_AW_TRACKER_ID = "test-tracker-123"; - - let capturedBody = null; - mockGithub.rest.issues.createComment = async params => { - capturedBody = params.body; - return { - data: { - id: 12345, - html_url: "https://github.com/owner/repo/issues/42#issuecomment-12345", - }, - }; - }; - - // Execute the handler - const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`); - - const message = { - type: "add_comment", - body: "User content with attempt", - }; - - const result = await handler(message, {}); - - expect(result.success).toBe(true); - expect(capturedBody).toBeDefined(); - // Verify tracker ID is present (not removed by sanitization) - expect(capturedBody).toContain(""); - // Verify script tags were sanitized (converted to safe format) - expect(capturedBody).not.toContain(" and "; - const sanitizedBody = sanitizeContent(userContent); - const trackerID = getTrackerID("markdown"); - const footer = generateFooterWithMessages("Test Workflow", "https://github.com/test/repo/actions/runs/123", "test.md", "https://github.com/test/repo", 42, undefined, undefined); - const result = sanitizedBody.trim() + trackerID + footer; - - // User content should be sanitized (tags converted) - expect(result).not.toContain(" Workflow"; - process.env.GH_AW_RUN_URL = "https://github.com/test/test/actions/runs/123"; - process.env.GH_AW_NOOP_MESSAGE = "Clean"; - process.env.GH_AW_AGENT_CONCLUSION = "success"; - - // Create agent output file with only noop outputs - const outputFile = path.join(tempDir, "agent_output.json"); - fs.writeFileSync( - outputFile, - JSON.stringify({ - items: [{ type: "noop", message: "Clean" }], - }) - ); - process.env.GH_AW_AGENT_OUTPUT = outputFile; - - mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ - data: { total_count: 1, items: [{ number: 1, node_id: "ID", html_url: "url" }] }, - }); - - mockGithub.rest.issues.createComment.mockResolvedValue({ data: {} }); - - const { main } = await import("./handle_noop_message.cjs?t=" + Date.now()); - await main(); - - const commentCall = mockGithub.rest.issues.createComment.mock.calls[0][0]; - // Verify XSS attempt was sanitized (specific behavior depends on sanitizeContent implementation) - expect(commentCall.body).not.toContain("]]>"); - expect(result).toBe("(![CDATA[(script)alert('xss')(/script)]])"); - }); - - it("should preserve inline formatting tags", () => { - const input = "This is bold, italic, and bold too text."; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve list structure tags", () => { - const input = ""; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve ordered list tags", () => { - const input = "
  1. First
  2. Second
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve blockquote tags", () => { - const input = "
This is a quote
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should handle mixed allowed tags with formatting", () => { - const input = "

This is bold and italic text.
New line here.

"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should handle nested list structure", () => { - const input = ""; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve details and summary tags", () => { - const result1 = sanitizeContent("
content
"); - expect(result1).toBe("
content
"); - - const result2 = sanitizeContent("content"); - expect(result2).toBe("content"); - }); - - it("should convert removed tags that are no longer allowed", () => { - // Tag that was previously allowed but is now removed: u - const result3 = sanitizeContent("content"); - expect(result3).toBe("(u)content(/u)"); - }); - - it("should preserve heading tags h1-h6", () => { - const headings = ["h1", "h2", "h3", "h4", "h5", "h6"]; - headings.forEach(tag => { - const input = `<${tag}>Heading`; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - }); - - it("should preserve hr tag", () => { - const result = sanitizeContent("Content before
Content after"); - expect(result).toBe("Content before
Content after"); - }); - - it("should preserve pre tag", () => { - const input = "
Code block content
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve sub and sup tags", () => { - const input1 = "H2O"; - const result1 = sanitizeContent(input1); - expect(result1).toBe(input1); - - const input2 = "E=mc2"; - const result2 = sanitizeContent(input2); - expect(result2).toBe(input2); - }); - - it("should preserve table structure tags", () => { - const input = "
Header
Data
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve span tag with title attribute", () => { - const input = 'prod: 2 days ago'; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve abbr tag with title attribute", () => { - const input = 'HTML'; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve del and ins tags", () => { - const input = "old text new text"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve kbd tag", () => { - const input = "Press Ctrl+C to copy"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve mark and s tags", () => { - const input = "highlighted and strikethrough"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - }); - - describe("ANSI escape sequence removal", () => { - it("should remove ANSI color codes", () => { - const result = sanitizeContent("\x1b[31mred text\x1b[0m"); - expect(result).toBe("red text"); - }); - - it("should remove various ANSI codes", () => { - const result = sanitizeContent("\x1b[1;32mBold Green\x1b[0m"); - expect(result).toBe("Bold Green"); - }); - }); - - describe("control character removal", () => { - it("should remove control characters", () => { - const result = sanitizeContent("test\x00\x01\x02\x03content"); - expect(result).toBe("testcontent"); - }); - - it("should preserve newlines and tabs", () => { - const result = sanitizeContent("test\ncontent\twith\ttabs"); - expect(result).toBe("test\ncontent\twith\ttabs"); - }); - - it("should remove DEL character", () => { - const result = sanitizeContent("test\x7Fcontent"); - expect(result).toBe("testcontent"); - }); - }); - - describe("URL protocol sanitization", () => { - it("should allow HTTPS URLs", () => { - const result = sanitizeContent("Visit https://github.com"); - expect(result).toBe("Visit https://github.com"); - }); - - it("should redact HTTP URLs with sanitized domain", () => { - const result = sanitizeContent("Visit http://example.com"); - expect(result).toContain("(example.com/redacted)"); - expect(mockCore.info).toHaveBeenCalled(); - }); - - it("should redact javascript: URLs", () => { - const result = sanitizeContent("Click javascript:alert('xss')"); - expect(result).toContain("(redacted)"); - }); - - it("should redact data: URLs", () => { - const result = sanitizeContent("Image data:image/png;base64,abc123"); - expect(result).toContain("(redacted)"); - }); - - it("should preserve file paths with colons", () => { - const result = sanitizeContent("C:\\path\\to\\file"); - expect(result).toBe("C:\\path\\to\\file"); - }); - - it("should preserve namespace patterns", () => { - const result = sanitizeContent("std::vector::push_back"); - expect(result).toBe("std::vector::push_back"); - }); - }); - - describe("URL domain filtering", () => { - it("should allow default GitHub domains", () => { - const urls = ["https://github.com/repo", "https://api.github.com/endpoint", "https://raw.githubusercontent.com/file", "https://example.github.io/page"]; - - urls.forEach(url => { - const result = sanitizeContent(`Visit ${url}`); - expect(result).toBe(`Visit ${url}`); - }); - }); - - it("should redact disallowed domains with sanitized domain", () => { - const result = sanitizeContent("Visit https://evil.com/malicious"); - expect(result).toContain("(evil.com/redacted)"); - expect(mockCore.info).toHaveBeenCalled(); - }); - - it("should use custom allowed domains from environment", () => { - process.env.GH_AW_ALLOWED_DOMAINS = "example.com,trusted.net"; - const result = sanitizeContent("Visit https://example.com/page"); - expect(result).toBe("Visit https://example.com/page"); - }); - - it("should extract and allow GitHub Enterprise domains", () => { - process.env.GITHUB_SERVER_URL = "https://github.company.com"; - const result = sanitizeContent("Visit https://github.company.com/repo"); - expect(result).toBe("Visit https://github.company.com/repo"); - }); - - it("should allow subdomains of allowed domains", () => { - const result = sanitizeContent("Visit https://subdomain.github.com/page"); - expect(result).toBe("Visit https://subdomain.github.com/page"); - }); - - it("should log redacted domains", () => { - sanitizeContent("Visit https://verylongdomainnamefortest.com/page"); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:")); - expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):")); - }); - - it("should support wildcard domain patterns (*.example.com)", () => { - process.env.GH_AW_ALLOWED_DOMAINS = "*.example.com"; - const result = sanitizeContent("Visit https://subdomain.example.com/page and https://another.example.com/path"); - expect(result).toBe("Visit https://subdomain.example.com/page and https://another.example.com/path"); - }); - - it("should allow base domain when wildcard pattern is used", () => { - process.env.GH_AW_ALLOWED_DOMAINS = "*.example.com"; - const result = sanitizeContent("Visit https://example.com/page"); - expect(result).toBe("Visit https://example.com/page"); - }); - - it("should redact domains not matching wildcard pattern", () => { - process.env.GH_AW_ALLOWED_DOMAINS = "*.example.com"; - const result = sanitizeContent("Visit https://evil.com/malicious"); - expect(result).toContain("(evil.com/redacted)"); - }); - - it("should support mixed wildcard and plain domains", () => { - process.env.GH_AW_ALLOWED_DOMAINS = "github.com,*.githubusercontent.com,api.example.com"; - const result = sanitizeContent("Visit https://github.com/repo, https://raw.githubusercontent.com/user/repo/main/file.txt, " + "https://api.example.com/endpoint, and https://subdomain.githubusercontent.com/file"); - expect(result).toBe("Visit https://github.com/repo, https://raw.githubusercontent.com/user/repo/main/file.txt, " + "https://api.example.com/endpoint, and https://subdomain.githubusercontent.com/file"); - }); - - it("should redact domains with wildcards that don't match pattern", () => { - process.env.GH_AW_ALLOWED_DOMAINS = "*.github.com"; - const result = sanitizeContent("Visit https://github.io/page"); - expect(result).toContain("(github.io/redacted)"); - }); - - it("should handle multiple levels of subdomains with wildcard", () => { - process.env.GH_AW_ALLOWED_DOMAINS = "*.example.com"; - const result = sanitizeContent("Visit https://deep.nested.example.com/page"); - expect(result).toBe("Visit https://deep.nested.example.com/page"); - }); - }); - - describe("domain sanitization", () => { - let sanitizeDomainName; - - beforeEach(async () => { - const module = await import("./sanitize_content_core.cjs"); - sanitizeDomainName = module.sanitizeDomainName; - }); - - it("should keep domains with 3 or fewer parts unchanged", () => { - expect(sanitizeDomainName("example.com")).toBe("example.com"); - expect(sanitizeDomainName("sub.example.com")).toBe("sub.example.com"); - // deep.sub.example.com has 4 parts, so it should be truncated - expect(sanitizeDomainName("a.b.c")).toBe("a.b.c"); - }); - - it("should keep domains under 48 characters unchanged", () => { - expect(sanitizeDomainName("a.b.c.d.com")).toBe("a.b.c.d.com"); - expect(sanitizeDomainName("one.two.three.four.five.com")).toBe("one.two.three.four.five.com"); - }); - - it("should remove non-alphanumeric characters from each part", () => { - expect(sanitizeDomainName("ex@mple.com")).toBe("exmple.com"); - expect(sanitizeDomainName("my-domain.co.uk")).toBe("mydomain.co.uk"); - expect(sanitizeDomainName("test_site.com")).toBe("testsite.com"); - }); - - it("should handle empty parts after sanitization", () => { - expect(sanitizeDomainName("...example.com")).toBe("example.com"); - expect(sanitizeDomainName("test..com")).toBe("test.com"); - expect(sanitizeDomainName("a.-.-.b.com")).toBe("a.b.com"); - }); - - it("should handle domains with ports", () => { - expect(sanitizeDomainName("example.com:8080")).toBe("example.com8080"); - }); - - it("should handle complex special characters", () => { - expect(sanitizeDomainName("ex!@#$ample.c%^&*om")).toBe("example.com"); - expect(sanitizeDomainName("test.ex@mple.co-uk")).toBe("test.exmple.couk"); - }); - - it("should handle null and undefined inputs", () => { - expect(sanitizeDomainName(null)).toBe(""); - expect(sanitizeDomainName(undefined)).toBe(""); - }); - - it("should handle empty string", () => { - expect(sanitizeDomainName("")).toBe(""); - }); - - it("should handle non-string inputs", () => { - expect(sanitizeDomainName(123)).toBe(""); - expect(sanitizeDomainName({})).toBe(""); - }); - - it("should handle domains that become empty after sanitization", () => { - expect(sanitizeDomainName("...")).toBe(""); - expect(sanitizeDomainName("@#$")).toBe(""); - }); - - it("should truncate domains longer than 48 characters to show first 24 and last 24", () => { - // This domain is 52 characters long - const longDomain = "very.long.subdomain.name.with.many.parts.example.com"; - const result = sanitizeDomainName(longDomain); - expect(result.length).toBe(49); // 24 + 1 (ellipsis) + 24 - expect(result).toBe("very.long.subdomain.name…h.many.parts.example.com"); - - // Another long domain test - expect(sanitizeDomainName("alpha.beta.gamma.delta.epsilon.com")).toBe("alpha.beta.gamma.delta.epsilon.com"); - }); - - it("should handle mixed case domains", () => { - expect(sanitizeDomainName("Example.COM")).toBe("Example.COM"); - expect(sanitizeDomainName("Sub.Example.Com")).toBe("Sub.Example.Com"); - }); - - it("should handle unicode characters", () => { - expect(sanitizeDomainName("tëst.com")).toBe("tst.com"); - expect(sanitizeDomainName("例え.com")).toBe("com"); - }); - - it("should apply sanitization in actual URL redaction for HTTP", () => { - const result = sanitizeContent("Visit http://sub.example.malicious.com/path"); - expect(result).toContain("(sub.example.malicious.com/redacted)"); - }); - - it("should apply sanitization in actual URL redaction for HTTPS", () => { - const result = sanitizeContent("Visit https://very.deep.nested.subdomain.evil.com/path"); - expect(result).toContain("(very.deep.nested.subdomain.evil.com/redacted)"); - }); - - it("should handle domains with special characters in URL context", () => { - // The regex captures domain up to first special character like @ - // So http://ex@mple-domain.co_uk.net captures only "ex" as domain - const result = sanitizeContent("Visit http://ex@mple-domain.co_uk.net/path"); - expect(result).toContain("(ex/redacted)"); - }); - - it("should preserve simple domain structure", () => { - const result = sanitizeContent("Visit http://test.com/path"); - expect(result).toContain("(test.com/redacted)"); - }); - - it("should handle subdomain with multiple parts correctly", () => { - // api.v2.example.com is under 48 chars, so it stays unchanged - const result = sanitizeContent("Visit http://api.v2.example.com/endpoint"); - expect(result).toContain("(api.v2.example.com/redacted)"); - }); - - it("should handle domains with many parts", () => { - // Under 48 chars - not truncated - expect(sanitizeDomainName("a.b.c.d.e.f.com")).toBe("a.b.c.d.e.f.com"); - }); - - it("should handle domains starting with numbers", () => { - expect(sanitizeDomainName("123.456.example.com")).toBe("123.456.example.com"); - }); - - it("should handle single part domain", () => { - expect(sanitizeDomainName("localhost")).toBe("localhost"); - }); - }); - - describe("bot trigger neutralization", () => { - it("should not neutralize 'fixes #123' when there are 10 or fewer references", () => { - const result = sanitizeContent("This fixes #123"); - expect(result).toBe("This fixes #123"); - }); - - it("should not neutralize 'closes #456' when there are 10 or fewer references", () => { - const result = sanitizeContent("PR closes #456"); - expect(result).toBe("PR closes #456"); - }); - - it("should not neutralize 'resolves #789' when there are 10 or fewer references", () => { - const result = sanitizeContent("This resolves #789"); - expect(result).toBe("This resolves #789"); - }); - - it("should not neutralize various bot trigger verbs when count is within limit", () => { - const triggers = ["fix", "fixes", "close", "closes", "resolve", "resolves"]; - triggers.forEach(verb => { - const result = sanitizeContent(`This ${verb} #123`); - expect(result).toBe(`This ${verb} #123`); - }); - }); - - it("should not neutralize alphanumeric issue references when count is within limit", () => { - const result = sanitizeContent("fixes #abc123def"); - expect(result).toBe("fixes #abc123def"); - }); - - it("should neutralize excess references beyond the 10-occurrence threshold", () => { - const input = Array.from({ length: 11 }, (_, i) => `fixes #${i + 1}`).join(" "); - const result = sanitizeContent(input); - // First 10 are left unchanged - for (let i = 1; i <= 10; i++) { - expect(result).not.toContain(`\`fixes #${i}\``); - } - // 11th is wrapped - expect(result).toContain("`fixes #11`"); - }); - - it("should not requote already-quoted entries", () => { - // Build a string with 12 entries where one is already quoted and 11 are unquoted - // (11 unquoted entries exceed the MAX_BOT_TRIGGER_REFERENCES threshold of 10) - const alreadyQuoted = "`fixes #1`"; - const unquoted = Array.from({ length: 11 }, (_, i) => `fixes #${i + 2}`).join(" "); - const input = `${alreadyQuoted} ${unquoted}`; - const result = sanitizeContent(input); - // The already-quoted entry must not be double-quoted - expect(result).not.toContain("``fixes #1``"); - expect(result).toContain("`fixes #1`"); - // The first 10 unquoted entries are left unchanged (only the 11th is wrapped) - for (let i = 2; i <= 11; i++) { - expect(result).not.toContain(`\`fixes #${i}\``); - } - // The 12th entry (11th unquoted) is wrapped - expect(result).toContain("`fixes #12`"); - }); - }); - - describe("GitHub reference neutralization", () => { - beforeEach(() => { - delete process.env.GH_AW_ALLOWED_GITHUB_REFS; - delete process.env.GITHUB_REPOSITORY; - }); - - afterEach(() => { - delete process.env.GH_AW_ALLOWED_GITHUB_REFS; - delete process.env.GITHUB_REPOSITORY; - }); - - it("should allow all references by default (no env var set)", () => { - const result = sanitizeContent("See issue #123 and owner/repo#456"); - // When no env var is set, all references are allowed - expect(result).toBe("See issue #123 and owner/repo#456"); - }); - - it("should restrict to current repo only when 'repo' is specified", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("See issue #123 and other/repo#456"); - expect(result).toBe("See issue #123 and `other/repo#456`"); - }); - - it("should allow current repo references with 'repo' keyword", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("See myorg/myrepo#123"); - expect(result).toBe("See myorg/myrepo#123"); - }); - - it("should allow specific repos in the list", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo,other/allowed-repo"; - - const result = sanitizeContent("See #123, other/allowed-repo#456, and bad/repo#789"); - expect(result).toBe("See #123, other/allowed-repo#456, and `bad/repo#789`"); - }); - - it("should handle multiple allowed repos", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "myorg/myrepo,other/repo,another/repo"; - - const result = sanitizeContent("Issues: myorg/myrepo#1, other/repo#2, another/repo#3, blocked/repo#4"); - expect(result).toBe("Issues: myorg/myrepo#1, other/repo#2, another/repo#3, `blocked/repo#4`"); - }); - - it("should be case-insensitive for repo names", () => { - process.env.GITHUB_REPOSITORY = "MyOrg/MyRepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("Issues: myorg/myrepo#123, MYORG/MYREPO#456"); - expect(result).toBe("Issues: myorg/myrepo#123, MYORG/MYREPO#456"); - }); - - it("should not escape references inside backticks", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("Already escaped: `other/repo#123`"); - expect(result).toBe("Already escaped: `other/repo#123`"); - }); - - it("should handle issue numbers with alphanumeric characters", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("See #abc123 and other/repo#def456"); - expect(result).toBe("See #abc123 and `other/repo#def456`"); - }); - - it("should handle references in different contexts", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("Start #123 middle other/repo#456 end"); - expect(result).toBe("Start #123 middle `other/repo#456` end"); - }); - - it("should trim whitespace in allowed-refs list", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = " repo , other/repo "; - - const result = sanitizeContent("See myorg/myrepo#123 and other/repo#456"); - expect(result).toBe("See myorg/myrepo#123 and other/repo#456"); - }); - - it("should log when escaping references", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - sanitizeContent("See other/repo#123"); - expect(mockCore.info).toHaveBeenCalledWith("Escaped GitHub reference: other/repo#123 (not in allowed list)"); - }); - - it("should escape all references when allowed-refs is empty array", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = ""; - - const result = sanitizeContent("See #123 and myorg/myrepo#456 and other/repo#789"); - expect(result).toBe("See `#123` and `myorg/myrepo#456` and `other/repo#789`"); - }); - - it("should handle empty allowed-refs list (all references escaped)", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = ""; - - const result = sanitizeContent("See #123 and other/repo#456"); - expect(result).toBe("See `#123` and `other/repo#456`"); - }); - - it("should escape references when current repo is not in list", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "other/allowed"; - - const result = sanitizeContent("See #123 and myorg/myrepo#456"); - expect(result).toBe("See `#123` and `myorg/myrepo#456`"); - }); - - it("should handle references with hyphens in repo names", () => { - process.env.GITHUB_REPOSITORY = "my-org/my-repo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("See my-org/my-repo#123 and other-org/other-repo#456"); - expect(result).toBe("See my-org/my-repo#123 and `other-org/other-repo#456`"); - }); - - it("should handle references with underscores in repo names", () => { - process.env.GITHUB_REPOSITORY = "myorg/my_repo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("See myorg/my_repo#123 and otherorg/other_repo#456"); - expect(result).toBe("See myorg/my_repo#123 and `otherorg/other_repo#456`"); - }); - - it("should handle references with dots in repo names", () => { - process.env.GITHUB_REPOSITORY = "myorg/my.repo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo,other/repo.test"; - - const result = sanitizeContent("See myorg/my.repo#123 and other/repo.test#456"); - expect(result).toBe("See myorg/my.repo#123 and other/repo.test#456"); - }); - - it("should handle multiple references in same sentence", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo,other/allowed"; - - const result = sanitizeContent("Related to #1, #2, other/allowed#3, and blocked/repo#4"); - expect(result).toBe("Related to #1, #2, other/allowed#3, and `blocked/repo#4`"); - }); - - it("should handle references at start and end of string", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("#123 in the middle other/repo#456"); - expect(result).toBe("#123 in the middle `other/repo#456`"); - }); - - it("should not escape references in code blocks", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("Code: `other/repo#123` end"); - expect(result).toBe("Code: `other/repo#123` end"); - }); - - it("should handle mixed case in repo specification", () => { - process.env.GITHUB_REPOSITORY = "MyOrg/MyRepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "myorg/myrepo,Other/Repo"; - - const result = sanitizeContent("See MyOrg/MyRepo#1, myorg/myrepo#2, OTHER/REPO#3, blocked/repo#4"); - expect(result).toBe("See MyOrg/MyRepo#1, myorg/myrepo#2, OTHER/REPO#3, `blocked/repo#4`"); - }); - - it("should handle very long issue numbers", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("See #123456789012345 and other/repo#999999999"); - expect(result).toBe("See #123456789012345 and `other/repo#999999999`"); - }); - - it("should handle no GITHUB_REPOSITORY env var with 'repo' keyword", () => { - delete process.env.GITHUB_REPOSITORY; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("See #123 and other/repo#456"); - // When GITHUB_REPOSITORY is not set, #123 targets empty string which won't match "repo", so not escaped - // But since we're trying to restrict to "repo" only, and current repo is unknown, all refs stay as-is - // because the restriction only applies when it can be determined - expect(result).toBe("See #123 and `other/repo#456`"); - }); - - it("should handle specific repo allowed but not current", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "other/specific"; - - const result = sanitizeContent("See #123 and other/specific#456"); - expect(result).toBe("See `#123` and other/specific#456"); - }); - - it("should preserve spacing around escaped references", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo"; - - const result = sanitizeContent("Before other/repo#123 after"); - expect(result).toBe("Before `other/repo#123` after"); - }); - - it("should allow all repos when wildcard * is used", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "*"; - - const result = sanitizeContent("See myorg/myrepo#123, other/repo#456, and another/repo#789"); - expect(result).toBe("See myorg/myrepo#123, other/repo#456, and another/repo#789"); - }); - - it("should allow repos matching org wildcard pattern", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "myorg/*"; - - const result = sanitizeContent("See myorg/myrepo#123, myorg/otherrepo#456, and other/repo#789"); - expect(result).toBe("See myorg/myrepo#123, myorg/otherrepo#456, and `other/repo#789`"); - }); - - it("should allow repos matching wildcard in combination with repo keyword", () => { - process.env.GITHUB_REPOSITORY = "myorg/myrepo"; - process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo,trusted/*"; - - const result = sanitizeContent("See #123, myorg/myrepo#456, trusted/lib#789, and other/repo#101"); - expect(result).toBe("See #123, myorg/myrepo#456, trusted/lib#789, and `other/repo#101`"); - }); - }); - - describe("content truncation", () => { - it("should truncate content exceeding max length", () => { - const longContent = "x".repeat(600000); - const result = sanitizeContent(longContent); - - expect(result.length).toBeLessThan(longContent.length); - expect(result).toContain("[Content truncated due to length]"); - }); - - it("should truncate content exceeding max lines", () => { - const manyLines = Array(70000).fill("line").join("\n"); - const result = sanitizeContent(manyLines); - - expect(result.split("\n").length).toBeLessThan(70000); - expect(result).toContain("[Content truncated due to line count]"); - }); - - it("should respect custom max length parameter", () => { - const content = "x".repeat(200); - const result = sanitizeContent(content, 100); - - expect(result.length).toBeLessThanOrEqual(100 + 50); // +50 for truncation message - expect(result).toContain("[Content truncated"); - }); - - it("should not truncate short content", () => { - const shortContent = "This is a short message"; - const result = sanitizeContent(shortContent); - - expect(result).toBe(shortContent); - expect(result).not.toContain("[Content truncated"); - }); - }); - - describe("combined sanitization", () => { - it("should apply all sanitizations correctly", () => { - const input = ` - - Hello @user, visit https://github.com - - This fixes #123 - \x1b[31mRed text\x1b[0m - `; - - const result = sanitizeContent(input); - - expect(result).not.toContain(""); - expect(result).toContain("`@user`"); - expect(result).toContain("https://github.com"); - expect(result).not.toContain(""]; - - maliciousInputs.forEach(input => { - const result = sanitizeContent(input); - expect(result).not.toContain(" { - const input = "
Header
Data
"; - const result = sanitizeContent(input); - - expect(result).toBe(input); - }); - }); - - describe("edge cases", () => { - it("should handle empty string", () => { - expect(sanitizeContent("")).toBe(""); - }); - - it("should handle whitespace-only input", () => { - expect(sanitizeContent(" \n\t ")).toBe(""); - }); - - it("should handle content with only control characters", () => { - const result = sanitizeContent("\x00\x01\x02\x03"); - expect(result).toBe(""); - }); - - it("should handle content with multiple consecutive spaces", () => { - const result = sanitizeContent("hello world"); - expect(result).toBe("hello world"); - }); - - it("should handle Unicode characters", () => { - const result = sanitizeContent("Hello 世界 🌍"); - expect(result).toBe("Hello 世界 🌍"); - }); - - it("should handle URLs in query parameters", () => { - const input = "https://github.com/redirect?url=https://github.com/target"; - const result = sanitizeContent(input); - - expect(result).toContain("github.com"); - expect(result).not.toContain("(redacted)"); - }); - - it("should handle nested backticks", () => { - const result = sanitizeContent("Already `@user` and @other"); - expect(result).toBe("Already `@user` and `@other`"); - }); - }); - - describe("redacted domains collection", () => { - let getRedactedDomains; - let clearRedactedDomains; - let writeRedactedDomainsLog; - const fs = require("fs"); - const path = require("path"); - - beforeEach(async () => { - const module = await import("./sanitize_content.cjs"); - getRedactedDomains = module.getRedactedDomains; - clearRedactedDomains = module.clearRedactedDomains; - writeRedactedDomainsLog = module.writeRedactedDomainsLog; - // Clear collected domains before each test - clearRedactedDomains(); - }); - - it("should collect redacted HTTPS domains", () => { - sanitizeContent("Visit https://evil.com/malware"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(1); - expect(domains[0]).toBe("evil.com"); - }); - - it("should collect redacted HTTP domains", () => { - sanitizeContent("Visit http://example.com"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(1); - expect(domains[0]).toBe("example.com"); - }); - - it("should collect redacted dangerous protocols", () => { - sanitizeContent("Click javascript:alert(1)"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(1); - expect(domains[0]).toBe("javascript:"); - }); - - it("should collect multiple redacted domains", () => { - sanitizeContent("Visit https://bad1.com and http://bad2.com"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(2); - expect(domains).toContain("bad1.com"); - expect(domains).toContain("bad2.com"); - }); - - it("should not collect allowed domains", () => { - sanitizeContent("Visit https://github.com/repo"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(0); - }); - - it("should clear collected domains", () => { - sanitizeContent("Visit https://evil.com"); - expect(getRedactedDomains().length).toBe(1); - clearRedactedDomains(); - expect(getRedactedDomains().length).toBe(0); - }); - - it("should return a copy of domains array", () => { - sanitizeContent("Visit https://evil.com"); - const domains1 = getRedactedDomains(); - const domains2 = getRedactedDomains(); - expect(domains1).not.toBe(domains2); - expect(domains1).toEqual(domains2); - }); - - describe("writeRedactedDomainsLog", () => { - const testDir = "/tmp/gh-aw-test-redacted"; - const testFile = `${testDir}/redacted-urls.log`; - - afterEach(() => { - // Clean up test files - if (fs.existsSync(testFile)) { - fs.unlinkSync(testFile); - } - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true, force: true }); - } - }); - - it("should return null when no domains collected", () => { - const result = writeRedactedDomainsLog(testFile); - expect(result).toBeNull(); - expect(fs.existsSync(testFile)).toBe(false); - }); - - it("should write domains to log file", () => { - sanitizeContent("Visit https://evil.com/malware"); - const result = writeRedactedDomainsLog(testFile); - expect(result).toBe(testFile); - expect(fs.existsSync(testFile)).toBe(true); - - const content = fs.readFileSync(testFile, "utf8"); - expect(content).toContain("evil.com"); - // Should NOT contain the full URL, only the domain - expect(content).not.toContain("https://evil.com/malware"); - }); - - it("should write multiple domains to log file", () => { - sanitizeContent("Visit https://bad1.com and http://bad2.com"); - writeRedactedDomainsLog(testFile); - - const content = fs.readFileSync(testFile, "utf8"); - const lines = content.trim().split("\n"); - expect(lines.length).toBe(2); - expect(content).toContain("bad1.com"); - expect(content).toContain("bad2.com"); - }); - - it("should create directory if it does not exist", () => { - const nestedFile = `${testDir}/nested/redacted-urls.log`; - sanitizeContent("Visit https://evil.com"); - writeRedactedDomainsLog(nestedFile); - expect(fs.existsSync(nestedFile)).toBe(true); - - // Clean up nested directory - fs.unlinkSync(nestedFile); - fs.rmdirSync(path.dirname(nestedFile)); - }); - - it("should use default path when not specified", () => { - const defaultPath = "/tmp/gh-aw/redacted-urls.log"; - sanitizeContent("Visit https://evil.com"); - const result = writeRedactedDomainsLog(); - expect(result).toBe(defaultPath); - expect(fs.existsSync(defaultPath)).toBe(true); - - // Clean up - fs.unlinkSync(defaultPath); - }); - }); - }); - - describe("Unicode hardening transformations", () => { - describe("zero-width character removal", () => { - it("should remove zero-width space (U+200B)", () => { - const input = "Hello\u200BWorld"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove zero-width non-joiner (U+200C)", () => { - const input = "Test\u200CText"; - const expected = "TestText"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove zero-width joiner (U+200D)", () => { - const input = "Hello\u200DWorld"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove word joiner (U+2060)", () => { - const input = "Word\u2060Joiner"; - const expected = "WordJoiner"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove byte order mark (U+FEFF)", () => { - const input = "\uFEFFHello World"; - const expected = "Hello World"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove multiple zero-width characters", () => { - const input = "A\u200BB\u200CC\u200DD\u2060E\uFEFFF"; - const expected = "ABCDEF"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should handle text with only zero-width characters", () => { - const input = "\u200B\u200C\u200D"; - const expected = ""; - expect(sanitizeContent(input)).toBe(expected); - }); - }); - - describe("Unicode normalization (NFC)", () => { - it("should normalize composed characters", () => { - // e + combining acute accent -> precomposed é - const input = "cafe\u0301"; // café with combining accent - const result = sanitizeContent(input); - // After NFC normalization, should be composed form - expect(result).toBe("café"); - // Verify it's the precomposed character (U+00E9) - expect(result.charCodeAt(3)).toBe(0x00e9); - }); - - it("should normalize multiple combining characters", () => { - const input = "n\u0303"; // ñ with combining tilde - const result = sanitizeContent(input); - expect(result).toBe("ñ"); - }); - - it("should handle already normalized text", () => { - const input = "Hello World"; - const expected = "Hello World"; - expect(sanitizeContent(input)).toBe(expected); - }); - }); - - describe("full-width ASCII conversion", () => { - it("should convert full-width exclamation mark", () => { - const input = "Hello\uFF01"; // Full-width ! - const expected = "Hello!"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should convert full-width letters", () => { - const input = "\uFF21\uFF22\uFF23"; // Full-width ABC - const expected = "ABC"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should convert full-width digits", () => { - const input = "\uFF11\uFF12\uFF13"; // Full-width 123 - const expected = "123"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should convert full-width parentheses", () => { - const input = "\uFF08test\uFF09"; // Full-width (test) - const expected = "(test)"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should convert mixed full-width and normal text", () => { - const input = "Hello\uFF01 \uFF37orld"; // Hello! World with full-width ! and W - const expected = "Hello! World"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should convert full-width at sign", () => { - const input = "\uFF20user"; // Full-width @user - // Note: @ mention will also be neutralized - const result = sanitizeContent(input); - expect(result).toBe("`@user`"); - }); - - it("should handle entire sentence in full-width", () => { - const input = "\uFF28\uFF45\uFF4C\uFF4C\uFF4F"; // Full-width Hello - const expected = "Hello"; - expect(sanitizeContent(input)).toBe(expected); - }); - }); - - describe("directional override removal", () => { - it("should remove left-to-right embedding (U+202A)", () => { - const input = "Hello\u202AWorld"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove right-to-left embedding (U+202B)", () => { - const input = "Hello\u202BWorld"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove pop directional formatting (U+202C)", () => { - const input = "Hello\u202CWorld"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove left-to-right override (U+202D)", () => { - const input = "Hello\u202DWorld"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove right-to-left override (U+202E)", () => { - const input = "Hello\u202EWorld"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove left-to-right isolate (U+2066)", () => { - const input = "Hello\u2066World"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove right-to-left isolate (U+2067)", () => { - const input = "Hello\u2067World"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove first strong isolate (U+2068)", () => { - const input = "Hello\u2068World"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove pop directional isolate (U+2069)", () => { - const input = "Hello\u2069World"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should remove multiple directional controls", () => { - const input = "A\u202AB\u202BC\u202CD\u202DE\u202EF\u2066G\u2067H\u2068I\u2069J"; - const expected = "ABCDEFGHIJ"; - expect(sanitizeContent(input)).toBe(expected); - }); - }); - - describe("combined Unicode attacks", () => { - it("should handle combination of zero-width and directional controls", () => { - const input = "Hello\u200B\u202EWorld\u200C"; - const expected = "HelloWorld"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should handle combination of full-width and zero-width", () => { - const input = "\uFF28\u200Bello"; // Full-width H + zero-width space + ello - const expected = "Hello"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should handle all transformations together", () => { - // Full-width H, zero-width space, combining accent, RTL override, normal text - const input = "\uFF28\u200Be\u0301\u202Ello"; - const expected = "Héllo"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should prevent visual spoofing with mixed scripts", () => { - // Example: trying to hide malicious text with RTL override - const input = "filename\u202E.txt.exe"; - // Should remove the RTL override - const expected = "filename.txt.exe"; - expect(sanitizeContent(input)).toBe(expected); - }); - - it("should handle deeply nested Unicode attacks", () => { - const input = "\uFEFF\u200B\uFF21\u202E\u0301\u200C"; - // BOM + ZWS + full-width A + RTL + combining + ZWNJ - const result = sanitizeContent(input); - // Should result in just "A" with the combining accent normalized - expect(result.replace(/\u0301/g, "")).toBe("A"); - }); - }); - - describe("edge cases and boundary conditions", () => { - it("should handle empty string", () => { - expect(sanitizeContent("")).toBe(""); - }); - - it("should handle string with only invisible characters", () => { - const input = "\u200B\u202E\uFEFF"; - expect(sanitizeContent(input)).toBe(""); - }); - - it("should preserve regular whitespace", () => { - const input = "Hello World\t\nTest"; - const result = sanitizeContent(input); - // Should preserve spaces, tabs, and newlines (though trimmed at end) - expect(result).toContain("Hello"); - expect(result).toContain("World"); - }); - - it("should not affect emoji", () => { - const input = "Hello 👋 World 🌍"; - const result = sanitizeContent(input); - expect(result).toContain("👋"); - expect(result).toContain("🌍"); - }); - - it("should handle long text with scattered Unicode attacks", () => { - const longText = "A".repeat(100) + "\u200B" + "B".repeat(100) + "\u202E" + "C".repeat(100); - const result = sanitizeContent(longText); - // Should remove the invisible characters - expect(result.length).toBe(300); // 100 + 100 + 100 - expect(result.includes("\u200B")).toBe(false); - expect(result.includes("\u202E")).toBe(false); - }); - }); - }); - - describe("HTML entity decoding for @mention bypass prevention", () => { - it("should decode @ and neutralize resulting @mention", () => { - const result = sanitizeContent("Please review @pelikhan"); - expect(result).toBe("Please review `@pelikhan`"); - }); - - it("should decode double-encoded &commat; and neutralize resulting @mention", () => { - const result = sanitizeContent("Please review &commat;pelikhan"); - expect(result).toBe("Please review `@pelikhan`"); - }); - - it("should decode @ (decimal) and neutralize resulting @mention", () => { - const result = sanitizeContent("Please review @pelikhan"); - expect(result).toBe("Please review `@pelikhan`"); - }); - - it("should decode double-encoded &#64; and neutralize resulting @mention", () => { - const result = sanitizeContent("Please review &#64;pelikhan"); - expect(result).toBe("Please review `@pelikhan`"); - }); - - it("should decode @ (hex lowercase) and neutralize resulting @mention", () => { - const result = sanitizeContent("Please review @pelikhan"); - expect(result).toBe("Please review `@pelikhan`"); - }); - - it("should decode @ (hex uppercase) and neutralize resulting @mention", () => { - const result = sanitizeContent("Please review @pelikhan"); - expect(result).toBe("Please review `@pelikhan`"); - }); - - it("should decode double-encoded &#x40; and neutralize resulting @mention", () => { - const result = sanitizeContent("Please review &#x40;pelikhan"); - expect(result).toBe("Please review `@pelikhan`"); - }); - - it("should decode double-encoded &#X40; and neutralize resulting @mention", () => { - const result = sanitizeContent("Please review &#X40;pelikhan"); - expect(result).toBe("Please review `@pelikhan`"); - }); - - it("should decode multiple HTML-encoded @mentions", () => { - const result = sanitizeContent("@user1 and @user2 and @user3"); - expect(result).toBe("`@user1` and `@user2` and `@user3`"); - }); - - it("should decode mixed HTML entities and normal @mentions", () => { - const result = sanitizeContent("@user1 and @user2"); - expect(result).toBe("`@user1` and `@user2`"); - }); - - it("should decode HTML entities in org/team mentions", () => { - const result = sanitizeContent("@myorg/myteam should review"); - expect(result).toBe("`@myorg/myteam` should review"); - }); - - it("should decode general decimal entities correctly", () => { - const result = sanitizeContent("Hello"); // "Hello" - expect(result).toBe("Hello"); - }); - - it("should decode general hex entities correctly", () => { - const result = sanitizeContent("Hello"); // "Hello" - expect(result).toBe("Hello"); - }); - - it("should decode double-encoded general entities correctly", () => { - const result = sanitizeContent("&#72;ello"); // "&Hello" - expect(result).toBe("Hello"); - }); - - it("should handle invalid code points gracefully", () => { - const result = sanitizeContent("Invalid � entity"); - expect(result).toBe("Invalid � entity"); // Keep original if invalid - }); - - it("should handle malformed HTML entities without crashing", () => { - const result = sanitizeContent("Malformed &# or &#x entity"); - expect(result).toBe("Malformed &# or &#x entity"); - }); - - it("should decode entities before Unicode hardening", () => { - // Ensure entity decoding happens as part of hardenUnicodeText - const result = sanitizeContent("!"); // Full-width exclamation (U+FF01) - expect(result).toBe("!"); // Should become ASCII ! - }); - - it("should decode entities in combination with other sanitization", () => { - const result = sanitizeContent("@user text"); - expect(result).toBe("`@user` text"); - }); - - it("should decode entities even in backticks (security-first approach)", () => { - // Entities are decoded during Unicode hardening, which happens before - // mention neutralization. This is intentional - we decode entities early - // to prevent bypasses, then the @mention gets neutralized properly. - const result = sanitizeContent("`@user`"); - expect(result).toBe("`@user`"); - }); - - it("should preserve legitimate URLs after entity decoding", () => { - const result = sanitizeContent("Visit https://github.com/user"); - expect(result).toBe("Visit https://github.com/user"); - }); - - it("should decode case-insensitive named entities", () => { - const result = sanitizeContent("&COMMAT;user and &CoMmAt;user2"); - expect(result).toBe("`@user` and `@user2`"); - }); - - it("should decode entities with mixed case hex digits", () => { - const result = sanitizeContent("O; is invalid but J is valid"); // Note: using letter 'O' not digit '0' - expect(result).toContain("O;"); // Invalid should remain - expect(result).toContain("J"); // Valid 0x4A = J - }); - - it("should handle zero code point", () => { - const result = sanitizeContent("�text"); - // Code point 0 is valid but typically removed as control character - expect(result).toContain("text"); - }); - - it("should respect allowed aliases even with HTML-encoded mentions", () => { - const result = sanitizeContent("@author is allowed", { allowedAliases: ["author"] }); - expect(result).toBe("@author is allowed"); - }); - - it("should decode > entity to > to prevent literal > in output", () => { - const result = sanitizeContent("value > threshold"); - expect(result).toBe("value > threshold"); - }); - - it("should decode double-encoded &gt; entity to >", () => { - const result = sanitizeContent("value &gt; threshold"); - expect(result).toBe("value > threshold"); - }); - - it("should decode < entity to < and then neutralize resulting tags", () => { - const result = sanitizeContent("<script> injection"); - // < → < and > → >, then convertXmlTags turns ", "${7*7}", "{{constructor.constructor('alert(1)')()}}", "../../../etc/passwd", "$(whoami)", "`ls -la`"]; - - maliciousPayloads.forEach(payload => { - const result = validateNumericValue(payload, "TEST_VAR"); - expect(result.valid).toBe(false); - expect(result.message).toContain("non-numeric"); - }); - }); - - it("should reject extremely large numbers outside safe integer range", () => { - const tooLarge = "9007199254740992"; // Number.MAX_SAFE_INTEGER + 1 - const result = validateNumericValue(tooLarge, "TEST_VAR"); - expect(result.valid).toBe(false); - expect(result.message).toContain("outside safe integer range"); - }); - - it("should reject extremely small numbers outside safe integer range", () => { - const tooSmall = "-9007199254740992"; // Number.MIN_SAFE_INTEGER - 1 - const result = validateNumericValue(tooSmall, "TEST_VAR"); - expect(result.valid).toBe(false); - expect(result.message).toContain("outside safe integer range"); - }); - - it("should accept numbers at the edge of safe integer range", () => { - const maxSafe = "9007199254740991"; // Number.MAX_SAFE_INTEGER - const result = validateNumericValue(maxSafe, "TEST_VAR"); - expect(result.valid).toBe(true); - - const minSafe = "-9007199254740991"; // Number.MIN_SAFE_INTEGER - const result2 = validateNumericValue(minSafe, "TEST_VAR"); - expect(result2.valid).toBe(true); - }); -}); - -describe("getNestedValue", () => { - let getNestedValue; - - beforeEach(async () => { - const module = await import("./validate_context_variables.cjs"); - getNestedValue = module.getNestedValue; - }); - - it("should get nested values from objects", () => { - const obj = { - payload: { - issue: { - number: 123, - }, - }, - }; - - const result = getNestedValue(obj, ["payload", "issue", "number"]); - expect(result).toBe(123); - }); - - it("should return undefined for missing paths", () => { - const obj = { - payload: {}, - }; - - const result = getNestedValue(obj, ["payload", "issue", "number"]); - expect(result).toBeUndefined(); - }); - - it("should return undefined for null/undefined intermediate values", () => { - const obj = { - payload: null, - }; - - const result = getNestedValue(obj, ["payload", "issue", "number"]); - expect(result).toBeUndefined(); - }); - - it("should handle empty path", () => { - const obj = { value: 42 }; - const result = getNestedValue(obj, []); - expect(result).toEqual(obj); - }); -}); - -describe("NUMERIC_CONTEXT_PATHS", () => { - let NUMERIC_CONTEXT_PATHS; - - beforeEach(async () => { - const module = await import("./validate_context_variables.cjs"); - NUMERIC_CONTEXT_PATHS = module.NUMERIC_CONTEXT_PATHS; - }); - - it("should include all expected numeric variables", () => { - const expectedPaths = [ - { path: ["payload", "issue", "number"], name: "github.event.issue.number" }, - { path: ["payload", "pull_request", "number"], name: "github.event.pull_request.number" }, - { path: ["payload", "discussion", "number"], name: "github.event.discussion.number" }, - { path: ["run_id"], name: "github.run_id" }, - { path: ["run_number"], name: "github.run_number" }, - ]; - - expectedPaths.forEach(expected => { - const found = NUMERIC_CONTEXT_PATHS.find(p => p.name === expected.name); - expect(found).toBeDefined(); - expect(found.path).toEqual(expected.path); - }); - }); - - it("should have 30 context paths", () => { - expect(NUMERIC_CONTEXT_PATHS.length).toBe(30); - }); - - it("should not include duplicate names", () => { - const names = NUMERIC_CONTEXT_PATHS.map(p => p.name); - const uniqueNames = [...new Set(names)]; - expect(uniqueNames.length).toBe(NUMERIC_CONTEXT_PATHS.length); - }); - - it("should not include github.event.head_commit.id (Git SHA, not numeric)", () => { - const found = NUMERIC_CONTEXT_PATHS.find(p => p.name === "github.event.head_commit.id"); - expect(found).toBeUndefined(); - }); -}); - -describe("main", () => { - let main; - let mockCore; - let mockContext; - - beforeEach(async () => { - vi.resetModules(); - - mockCore = { - info: vi.fn(), - error: vi.fn(), - setFailed: vi.fn(), - }; - - mockContext = { - payload: { - issue: { - number: 123, - }, - pull_request: { - number: 456, - }, - }, - run_id: 789, - run_number: 10, - }; - - global.core = mockCore; - global.context = mockContext; - - const module = await import("./validate_context_variables.cjs"); - main = module.main; - }); - - afterEach(() => { - delete global.core; - delete global.context; - }); - - it("should validate all numeric context variables successfully", async () => { - await main(); - - expect(mockCore.setFailed).not.toHaveBeenCalled(); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ All context variables validated successfully")); - }); - - it("should fail when a numeric field contains non-numeric data", async () => { - mockContext.payload.issue.number = "123; DROP TABLE users"; - - await expect(main()).rejects.toThrow(); - expect(mockCore.setFailed).toHaveBeenCalled(); - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("non-numeric")); - }); - - it("should pass when numeric fields are valid integers", async () => { - mockContext.payload.issue.number = 42; - mockContext.run_id = 12345; - - await main(); - - expect(mockCore.setFailed).not.toHaveBeenCalled(); - }); - - it("should not validate github.event.head_commit.id (Git SHA)", async () => { - // Add a Git commit SHA to the context - mockContext.payload.head_commit = { - id: "046ee07d682351acd49209ca43ba340931001c1a", - }; - - await main(); - - // Should pass because head_commit.id is not validated as numeric - expect(mockCore.setFailed).not.toHaveBeenCalled(); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ All context variables validated successfully")); - }); -}); - -describe("validateContextVariables", () => { - let validateContextVariables; - let mockCore; - - beforeEach(async () => { - vi.resetModules(); - - mockCore = { - info: vi.fn(), - error: vi.fn(), - setFailed: vi.fn(), - }; - - const module = await import("./validate_context_variables.cjs"); - validateContextVariables = module.validateContextVariables; - }); - - it("should validate successfully with explicit core and ctx parameters", async () => { - const ctx = { - payload: { issue: { number: 42 } }, - run_id: 789, - run_number: 10, - }; - - await validateContextVariables(mockCore, ctx); - - expect(mockCore.setFailed).not.toHaveBeenCalled(); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ All context variables validated successfully")); - }); - - it("should fail with explicit core and ctx when numeric field is invalid", async () => { - const ctx = { - payload: { issue: { number: "malicious; payload" } }, - }; - - await expect(validateContextVariables(mockCore, ctx)).rejects.toThrow(); - expect(mockCore.setFailed).toHaveBeenCalled(); - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("non-numeric")); - }); -}); diff --git a/setup/js/validate_lockdown_requirements.test.cjs b/setup/js/validate_lockdown_requirements.test.cjs deleted file mode 100644 index 3d248c5..0000000 --- a/setup/js/validate_lockdown_requirements.test.cjs +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; - -describe("validate_lockdown_requirements", () => { - let mockCore; - let validateLockdownRequirements; - - beforeEach(async () => { - vi.resetModules(); - - // Setup mock core - mockCore = { - info: vi.fn(), - setFailed: vi.fn(), - setOutput: vi.fn(), - }; - - // Reset process.env - delete process.env.GITHUB_MCP_LOCKDOWN_EXPLICIT; - delete process.env.GH_AW_GITHUB_TOKEN; - delete process.env.GH_AW_GITHUB_MCP_SERVER_TOKEN; - delete process.env.CUSTOM_GITHUB_TOKEN; - delete process.env.GITHUB_REPOSITORY_VISIBILITY; - delete process.env.GH_AW_COMPILED_STRICT; - - // Import the module - validateLockdownRequirements = (await import("./validate_lockdown_requirements.cjs")).default; - }); - - it("should skip lockdown validation when lockdown is not explicitly enabled", () => { - // GITHUB_MCP_LOCKDOWN_EXPLICIT not set - - validateLockdownRequirements(mockCore); - - expect(mockCore.info).toHaveBeenCalledWith("Lockdown mode not explicitly enabled, skipping validation"); - expect(mockCore.setFailed).not.toHaveBeenCalled(); - }); - - it("should pass validation when lockdown is enabled and GH_AW_GITHUB_TOKEN is configured", () => { - process.env.GITHUB_MCP_LOCKDOWN_EXPLICIT = "true"; - process.env.GH_AW_GITHUB_TOKEN = "ghp_test_token"; - - validateLockdownRequirements(mockCore); - - expect(mockCore.info).toHaveBeenCalledWith("Lockdown mode is explicitly enabled, validating requirements..."); - expect(mockCore.info).toHaveBeenCalledWith("GH_AW_GITHUB_TOKEN configured: true"); - expect(mockCore.info).toHaveBeenCalledWith("✓ Lockdown mode requirements validated: Custom GitHub token is configured"); - expect(mockCore.setFailed).not.toHaveBeenCalled(); - }); - - it("should pass validation when lockdown is enabled and GH_AW_GITHUB_MCP_SERVER_TOKEN is configured", () => { - process.env.GITHUB_MCP_LOCKDOWN_EXPLICIT = "true"; - process.env.GH_AW_GITHUB_MCP_SERVER_TOKEN = "ghp_mcp_token"; - - validateLockdownRequirements(mockCore); - - expect(mockCore.info).toHaveBeenCalledWith("Lockdown mode is explicitly enabled, validating requirements..."); - expect(mockCore.info).toHaveBeenCalledWith("GH_AW_GITHUB_MCP_SERVER_TOKEN configured: true"); - expect(mockCore.info).toHaveBeenCalledWith("✓ Lockdown mode requirements validated: Custom GitHub token is configured"); - expect(mockCore.setFailed).not.toHaveBeenCalled(); - }); - - it("should pass validation when lockdown is enabled and custom github-token is configured", () => { - process.env.GITHUB_MCP_LOCKDOWN_EXPLICIT = "true"; - process.env.CUSTOM_GITHUB_TOKEN = "ghp_custom_token"; - - validateLockdownRequirements(mockCore); - - expect(mockCore.info).toHaveBeenCalledWith("Lockdown mode is explicitly enabled, validating requirements..."); - expect(mockCore.info).toHaveBeenCalledWith("Custom github-token configured: true"); - expect(mockCore.info).toHaveBeenCalledWith("✓ Lockdown mode requirements validated: Custom GitHub token is configured"); - expect(mockCore.setFailed).not.toHaveBeenCalled(); - }); - - it("should fail when lockdown is enabled but no custom tokens are configured", () => { - process.env.GITHUB_MCP_LOCKDOWN_EXPLICIT = "true"; - // No custom tokens set - - expect(() => { - validateLockdownRequirements(mockCore); - }).toThrow("Lockdown mode is enabled"); - - expect(mockCore.info).toHaveBeenCalledWith("Lockdown mode is explicitly enabled, validating requirements..."); - expect(mockCore.info).toHaveBeenCalledWith("GH_AW_GITHUB_TOKEN configured: false"); - expect(mockCore.info).toHaveBeenCalledWith("GH_AW_GITHUB_MCP_SERVER_TOKEN configured: false"); - expect(mockCore.info).toHaveBeenCalledWith("Custom github-token configured: false"); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Lockdown mode is enabled (lockdown: true) but no custom GitHub token is configured")); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_AW_GITHUB_TOKEN (recommended)")); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_AW_GITHUB_MCP_SERVER_TOKEN (alternative)")); - expect(mockCore.setOutput).toHaveBeenCalledWith("lockdown_check_failed", "true"); - }); - - it("should include documentation link in error message", () => { - process.env.GITHUB_MCP_LOCKDOWN_EXPLICIT = "true"; - // No custom tokens set - - expect(() => { - validateLockdownRequirements(mockCore); - }).toThrow(); - - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/auth.mdx")); - }); - - it("should handle empty string tokens as not configured", () => { - process.env.GITHUB_MCP_LOCKDOWN_EXPLICIT = "true"; - process.env.GH_AW_GITHUB_TOKEN = ""; - process.env.GH_AW_GITHUB_MCP_SERVER_TOKEN = ""; - process.env.CUSTOM_GITHUB_TOKEN = ""; - - expect(() => { - validateLockdownRequirements(mockCore); - }).toThrow("Lockdown mode is enabled"); - - expect(mockCore.setFailed).toHaveBeenCalled(); - }); - - it("should skip lockdown validation when GITHUB_MCP_LOCKDOWN_EXPLICIT is false", () => { - process.env.GITHUB_MCP_LOCKDOWN_EXPLICIT = "false"; - // GH_AW_GITHUB_TOKEN not set - - validateLockdownRequirements(mockCore); - - expect(mockCore.info).toHaveBeenCalledWith("Lockdown mode not explicitly enabled, skipping validation"); - expect(mockCore.setFailed).not.toHaveBeenCalled(); - }); - - // Strict mode enforcement for public repositories - describe("strict mode enforcement for public repositories", () => { - it("should fail when repository is public and not compiled with strict mode", () => { - process.env.GITHUB_REPOSITORY_VISIBILITY = "public"; - process.env.GH_AW_COMPILED_STRICT = "false"; - - expect(() => { - validateLockdownRequirements(mockCore); - }).toThrow("not compiled with strict mode"); - - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("public repository but was not compiled with strict mode")); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("gh aw compile --strict")); - expect(mockCore.setOutput).toHaveBeenCalledWith("lockdown_check_failed", "true"); - }); - - it("should fail when repository is public and GH_AW_COMPILED_STRICT is not set", () => { - process.env.GITHUB_REPOSITORY_VISIBILITY = "public"; - // GH_AW_COMPILED_STRICT not set - - expect(() => { - validateLockdownRequirements(mockCore); - }).toThrow("not compiled with strict mode"); - - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("public repository but was not compiled with strict mode")); - expect(mockCore.setOutput).toHaveBeenCalledWith("lockdown_check_failed", "true"); - }); - - it("should pass when repository is public and compiled with strict mode", () => { - process.env.GITHUB_REPOSITORY_VISIBILITY = "public"; - process.env.GH_AW_COMPILED_STRICT = "true"; - - validateLockdownRequirements(mockCore); - - expect(mockCore.setFailed).not.toHaveBeenCalled(); - expect(mockCore.info).toHaveBeenCalledWith("✓ Strict mode requirements validated: Public repository compiled with strict mode"); - }); - - it("should pass when repository is private and not compiled with strict mode", () => { - process.env.GITHUB_REPOSITORY_VISIBILITY = "private"; - process.env.GH_AW_COMPILED_STRICT = "false"; - - validateLockdownRequirements(mockCore); - - expect(mockCore.setFailed).not.toHaveBeenCalled(); - }); - - it("should pass when repository is internal and not compiled with strict mode", () => { - process.env.GITHUB_REPOSITORY_VISIBILITY = "internal"; - process.env.GH_AW_COMPILED_STRICT = "false"; - - validateLockdownRequirements(mockCore); - - expect(mockCore.setFailed).not.toHaveBeenCalled(); - }); - - it("should pass when visibility is unknown and not compiled with strict mode", () => { - // GITHUB_REPOSITORY_VISIBILITY not set - process.env.GH_AW_COMPILED_STRICT = "false"; - - validateLockdownRequirements(mockCore); - - expect(mockCore.setFailed).not.toHaveBeenCalled(); - }); - - it("should include documentation link in strict mode error message", () => { - process.env.GITHUB_REPOSITORY_VISIBILITY = "public"; - process.env.GH_AW_COMPILED_STRICT = "false"; - - expect(() => { - validateLockdownRequirements(mockCore); - }).toThrow(); - - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/security.mdx")); - }); - - it("should validate both lockdown and strict mode when both are required", () => { - process.env.GITHUB_MCP_LOCKDOWN_EXPLICIT = "true"; - process.env.GH_AW_GITHUB_TOKEN = "ghp_test_token"; - process.env.GITHUB_REPOSITORY_VISIBILITY = "public"; - process.env.GH_AW_COMPILED_STRICT = "true"; - - validateLockdownRequirements(mockCore); - - expect(mockCore.setFailed).not.toHaveBeenCalled(); - expect(mockCore.info).toHaveBeenCalledWith("✓ Lockdown mode requirements validated: Custom GitHub token is configured"); - expect(mockCore.info).toHaveBeenCalledWith("✓ Strict mode requirements validated: Public repository compiled with strict mode"); - }); - - it("should fail on lockdown check before strict mode check when both fail", () => { - process.env.GITHUB_MCP_LOCKDOWN_EXPLICIT = "true"; - // No custom tokens - will fail on lockdown check - process.env.GITHUB_REPOSITORY_VISIBILITY = "public"; - process.env.GH_AW_COMPILED_STRICT = "false"; - - expect(() => { - validateLockdownRequirements(mockCore); - }).toThrow("Lockdown mode is enabled"); - - // Strict mode error should not be reached since lockdown check throws first - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Lockdown mode is enabled")); - }); - }); -}); diff --git a/setup/js/validate_memory_files.test.cjs b/setup/js/validate_memory_files.test.cjs deleted file mode 100644 index cfffaa6..0000000 --- a/setup/js/validate_memory_files.test.cjs +++ /dev/null @@ -1,209 +0,0 @@ -// @ts-check - -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import fs from "fs"; -import path from "path"; -import os from "os"; - -const { validateMemoryFiles } = require("./validate_memory_files.cjs"); - -// Mock core globally -global.core = { - info: () => {}, - error: () => {}, - warning: () => {}, - debug: () => {}, -}; - -describe("validateMemoryFiles", () => { - let tempDir = ""; - - beforeEach(() => { - // Create a temporary directory for testing - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "validate-memory-test-")); - }); - - afterEach(() => { - // Clean up temporary directory - if (tempDir && fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it("returns valid for empty directory", () => { - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); - expect(result.invalidFiles).toEqual([]); - }); - - it("returns valid for non-existent directory", () => { - const nonExistentDir = path.join(tempDir, "does-not-exist"); - const result = validateMemoryFiles(nonExistentDir, "cache"); - expect(result.valid).toBe(true); - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts .json files by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "data.json"), '{"test": true}'); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts .jsonl files by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "data.jsonl"), '{"line": 1}\n{"line": 2}'); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts .txt files by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "notes.txt"), "Some notes"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts .md files by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "README.md"), "# Title"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts .csv files by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "data.csv"), "col1,col2\nval1,val2"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts multiple valid files by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "data.json"), "{}"); - fs.writeFileSync(path.join(tempDir, "notes.txt"), "notes"); - fs.writeFileSync(path.join(tempDir, "README.md"), "# Title"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts .log files by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "app.log"), "log entry"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); // Now accepted when no restrictions - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts .yaml files by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "config.yaml"), "key: value"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); // Now accepted when no restrictions - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts .xml files by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "data.xml"), ""); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); // Now accepted when no restrictions - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts files without extension by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "noext"), "content"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); // Now accepted when no restrictions - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts all files by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "app.log"), "log"); - fs.writeFileSync(path.join(tempDir, "config.yaml"), "yaml"); - fs.writeFileSync(path.join(tempDir, "valid.json"), "{}"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); // All files accepted when no restrictions - expect(result.invalidFiles).toEqual([]); - }); - - it("validates files in subdirectories by default (allow all)", () => { - const subdir = path.join(tempDir, "subdir"); - fs.mkdirSync(subdir); - fs.writeFileSync(path.join(subdir, "valid.json"), "{}"); - fs.writeFileSync(path.join(subdir, "invalid.log"), "log"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); // All files accepted when no restrictions - expect(result.invalidFiles).toEqual([]); - }); - - it("validates files in deeply nested directories by default (allow all)", () => { - const level1 = path.join(tempDir, "level1"); - const level2 = path.join(level1, "level2"); - const level3 = path.join(level2, "level3"); - fs.mkdirSync(level1); - fs.mkdirSync(level2); - fs.mkdirSync(level3); - fs.writeFileSync(path.join(level3, "deep.json"), "{}"); - fs.writeFileSync(path.join(level3, "invalid.bin"), "binary"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); // All files accepted when no restrictions - expect(result.invalidFiles).toEqual([]); - }); - - it("is case-insensitive for extensions by default (allow all)", () => { - fs.writeFileSync(path.join(tempDir, "data.JSON"), "{}"); - fs.writeFileSync(path.join(tempDir, "notes.TXT"), "text"); - fs.writeFileSync(path.join(tempDir, "README.MD"), "# Title"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); - expect(result.invalidFiles).toEqual([]); - }); - - it("handles all files in subdirectories by default (allow all)", () => { - const subdir1 = path.join(tempDir, "valid-files"); - const subdir2 = path.join(tempDir, "invalid-files"); - fs.mkdirSync(subdir1); - fs.mkdirSync(subdir2); - fs.writeFileSync(path.join(subdir1, "data.json"), "{}"); - fs.writeFileSync(path.join(subdir1, "notes.txt"), "text"); - fs.writeFileSync(path.join(subdir2, "app.log"), "log"); - fs.writeFileSync(path.join(subdir2, "config.ini"), "ini"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); // All files accepted when no restrictions - expect(result.invalidFiles).toEqual([]); - }); - - it("accepts custom allowed extensions", () => { - fs.writeFileSync(path.join(tempDir, "config.yaml"), "key: value"); - fs.writeFileSync(path.join(tempDir, "data.xml"), ""); - const customExts = [".yaml", ".xml"]; - const result = validateMemoryFiles(tempDir, "cache", customExts); - expect(result.valid).toBe(true); - expect(result.invalidFiles).toEqual([]); - }); - - it("rejects files not in custom allowed extensions", () => { - fs.writeFileSync(path.join(tempDir, "data.json"), "{}"); - const customExts = [".yaml", ".xml"]; - const result = validateMemoryFiles(tempDir, "cache", customExts); - expect(result.valid).toBe(false); - expect(result.invalidFiles).toEqual(["data.json"]); - }); - - it("allows all files when custom array is empty", () => { - fs.writeFileSync(path.join(tempDir, "data.json"), "{}"); - fs.writeFileSync(path.join(tempDir, "notes.txt"), "text"); - fs.writeFileSync(path.join(tempDir, "app.log"), "log"); - fs.writeFileSync(path.join(tempDir, "config.yaml"), "key: value"); - const result = validateMemoryFiles(tempDir, "cache", []); - expect(result.valid).toBe(true); // Empty array means allow all - expect(result.invalidFiles).toEqual([]); - }); - - it("allows all files when allowedExtensions is undefined", () => { - fs.writeFileSync(path.join(tempDir, "data.json"), "{}"); - fs.writeFileSync(path.join(tempDir, "app.log"), "log"); - fs.writeFileSync(path.join(tempDir, "config.yaml"), "key: value"); - const result = validateMemoryFiles(tempDir, "cache"); - expect(result.valid).toBe(true); // undefined means allow all - expect(result.invalidFiles).toEqual([]); - }); -}); diff --git a/setup/js/validate_secrets.test.cjs b/setup/js/validate_secrets.test.cjs deleted file mode 100644 index 3687e13..0000000 --- a/setup/js/validate_secrets.test.cjs +++ /dev/null @@ -1,244 +0,0 @@ -// @ts-check - -import { describe, it, expect } from "vitest"; -import { testGitHubRESTAPI, testGitHubGraphQLAPI, testCopilotCLI, testAnthropicAPI, testOpenAIAPI, testBraveSearchAPI, testNotionAPI, generateMarkdownReport, isForkRepository } from "./validate_secrets.cjs"; - -describe("validate_secrets", () => { - describe("testGitHubRESTAPI", () => { - it("should return NOT_SET when token is not provided", async () => { - const result = await testGitHubRESTAPI("", "owner", "repo"); - expect(result.status).toBe("not_set"); - expect(result.message).toBe("Token not set"); - }); - - it("should return NOT_SET when token is null", async () => { - const result = await testGitHubRESTAPI(null, "owner", "repo"); - expect(result.status).toBe("not_set"); - expect(result.message).toBe("Token not set"); - }); - - it("should return NOT_SET when token is undefined", async () => { - const result = await testGitHubRESTAPI(undefined, "owner", "repo"); - expect(result.status).toBe("not_set"); - expect(result.message).toBe("Token not set"); - }); - }); - - describe("testGitHubGraphQLAPI", () => { - it("should return NOT_SET when token is not provided", async () => { - const result = await testGitHubGraphQLAPI("", "owner", "repo"); - expect(result.status).toBe("not_set"); - expect(result.message).toBe("Token not set"); - }); - }); - - describe("testCopilotCLI", () => { - it("should return NOT_SET when token is not provided", async () => { - const result = await testCopilotCLI(""); - expect(result.status).toBe("not_set"); - expect(result.message).toBe("Token not set"); - }); - }); - - describe("testAnthropicAPI", () => { - it("should return NOT_SET when API key is not provided", async () => { - const result = await testAnthropicAPI(""); - expect(result.status).toBe("not_set"); - expect(result.message).toBe("API key not set"); - }); - }); - - describe("testOpenAIAPI", () => { - it("should return NOT_SET when API key is not provided", async () => { - const result = await testOpenAIAPI(""); - expect(result.status).toBe("not_set"); - expect(result.message).toBe("API key not set"); - }); - }); - - describe("testBraveSearchAPI", () => { - it("should return NOT_SET when API key is not provided", async () => { - const result = await testBraveSearchAPI(""); - expect(result.status).toBe("not_set"); - expect(result.message).toBe("API key not set"); - }); - }); - - describe("testNotionAPI", () => { - it("should return NOT_SET when token is not provided", async () => { - const result = await testNotionAPI(""); - expect(result.status).toBe("not_set"); - expect(result.message).toBe("Token not set"); - }); - }); - - describe("generateMarkdownReport", () => { - it("should generate a report with summary and detailed results", () => { - const results = [ - { - secret: "TEST_SECRET", - test: "Test API", - status: "success", - message: "Test passed", - details: { statusCode: 200 }, - }, - { - secret: "ANOTHER_SECRET", - test: "Another Test", - status: "failure", - message: "Test failed", - details: { statusCode: 401 }, - }, - { - secret: "NOT_SET_SECRET", - test: "Not Set Test", - status: "not_set", - message: "Token not set", - }, - ]; - - const report = generateMarkdownReport(results); - - // Check that report contains expected sections - expect(report).toContain("📊 Summary"); - expect(report).toContain("🔍 Detailed Results"); - expect(report).toContain("TEST_SECRET"); - expect(report).toContain("ANOTHER_SECRET"); - expect(report).toContain("NOT_SET_SECRET"); - - // Check for status emojis - expect(report).toContain("✅"); - expect(report).toContain("❌"); - expect(report).toContain("⚪"); - - // Check for summary table - expect(report).toContain("| Status | Count | Percentage |"); - - // Check for recommendations - expect(report).toContain("[!WARNING]"); - expect(report).toContain("[!NOTE]"); - }); - - it("should generate a successful report when all secrets are valid", () => { - const results = [ - { - secret: "TEST_SECRET", - test: "Test API", - status: "success", - message: "Test passed", - details: { statusCode: 200 }, - }, - ]; - - const report = generateMarkdownReport(results); - - expect(report).toContain("📊 Summary"); - expect(report).toContain("[!TIP]"); - expect(report).toContain("All configured secrets are working correctly!"); - }); - - it("should include documentation links for secrets", () => { - const results = [ - { - secret: "GH_AW_GITHUB_TOKEN", - test: "GitHub REST API", - status: "failure", - message: "Invalid token", - details: { statusCode: 401 }, - }, - { - secret: "ANTHROPIC_API_KEY", - test: "Anthropic API", - status: "not_set", - message: "API key not set", - }, - ]; - - const report = generateMarkdownReport(results); - - // Check for GitHub docs link - expect(report).toContain("docs.github.com"); - expect(report).toContain("docs.anthropic.com"); - }); - - it("should handle empty results gracefully", () => { - const results = []; - - const report = generateMarkdownReport(results); - - expect(report).toContain("📊 Summary"); - expect(report).toContain("| **Total** | **0** | **100%** |"); - }); - - it("should handle skipped tests", () => { - const results = [ - { - secret: "SKIPPED_SECRET", - test: "Skipped Test", - status: "skipped", - message: "Test skipped", - }, - ]; - - const report = generateMarkdownReport(results); - - expect(report).toContain("⏭️"); - expect(report).toContain("Skipped"); - }); - - it("should group tests by secret", () => { - const results = [ - { - secret: "GH_AW_GITHUB_TOKEN", - test: "GitHub REST API", - status: "success", - message: "REST API successful", - }, - { - secret: "GH_AW_GITHUB_TOKEN", - test: "GitHub GraphQL API", - status: "success", - message: "GraphQL API successful", - }, - ]; - - const report = generateMarkdownReport(results); - - // Should show the secret once with 2 tests - expect(report).toContain("GH_AW_GITHUB_TOKEN"); - expect(report).toContain("(2 tests)"); - expect(report).toContain("GitHub REST API"); - expect(report).toContain("GitHub GraphQL API"); - }); - }); - - describe("isForkRepository", () => { - it("should return true when repository.fork is true", () => { - const payload = { repository: { fork: true } }; - expect(isForkRepository(payload)).toBe(true); - }); - - it("should return false when repository.fork is false", () => { - const payload = { repository: { fork: false } }; - expect(isForkRepository(payload)).toBe(false); - }); - - it("should return false when repository.fork is absent", () => { - const payload = { repository: {} }; - expect(isForkRepository(payload)).toBe(false); - }); - - it("should return false when repository is absent", () => { - const payload = {}; - expect(isForkRepository(payload)).toBe(false); - }); - - it("should return false when payload is null", () => { - expect(isForkRepository(null)).toBe(false); - }); - - it("should return false when payload is undefined", () => { - expect(isForkRepository(undefined)).toBe(false); - }); - }); -}); diff --git a/setup/js/workflow_metadata_helpers.test.cjs b/setup/js/workflow_metadata_helpers.test.cjs deleted file mode 100644 index 7c2af29..0000000 --- a/setup/js/workflow_metadata_helpers.test.cjs +++ /dev/null @@ -1,128 +0,0 @@ -// @ts-check - -const { getWorkflowMetadata, buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); - -describe("getWorkflowMetadata", () => { - let originalEnv; - let originalContext; - - beforeEach(() => { - // Save original environment - originalEnv = { ...process.env }; - - // Save and mock global context - originalContext = global.context; - global.context = { - runId: 123456, - payload: { - repository: { - html_url: "https://github.com/test-owner/test-repo", - }, - }, - }; - }); - - afterEach(() => { - // Restore environment by mutating process.env in place - for (const key of Object.keys(process.env)) { - if (!(key in originalEnv)) { - delete process.env[key]; - } - } - Object.assign(process.env, originalEnv); - - // Restore original context - global.context = originalContext; - }); - - it("should extract workflow metadata from environment and context", () => { - // Set environment variables - process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; - process.env.GH_AW_WORKFLOW_ID = "test-workflow-id"; - process.env.GITHUB_SERVER_URL = "https://github.com"; - - const metadata = getWorkflowMetadata("test-owner", "test-repo"); - - expect(metadata).toEqual({ - workflowName: "Test Workflow", - workflowId: "test-workflow-id", - runId: 123456, - runUrl: "https://github.com/test-owner/test-repo/actions/runs/123456", - }); - }); - - it("should use defaults when environment variables are missing", () => { - // Clear environment variables - delete process.env.GH_AW_WORKFLOW_NAME; - delete process.env.GH_AW_WORKFLOW_ID; - delete process.env.GITHUB_SERVER_URL; - - const metadata = getWorkflowMetadata("test-owner", "test-repo"); - - expect(metadata.workflowName).toBe("Workflow"); - expect(metadata.workflowId).toBe(""); - expect(metadata.runId).toBe(123456); - expect(metadata.runUrl).toBe("https://github.com/test-owner/test-repo/actions/runs/123456"); - }); - - it("should construct runUrl from githubServer when repository payload is missing", () => { - // Mock context without repository payload - global.context = { - runId: 789012, - payload: {}, - }; - - process.env.GITHUB_SERVER_URL = "https://github.enterprise.com"; - - const metadata = getWorkflowMetadata("enterprise-owner", "enterprise-repo"); - - expect(metadata.runUrl).toBe("https://github.enterprise.com/enterprise-owner/enterprise-repo/actions/runs/789012"); - }); - - it("should handle missing context gracefully", () => { - // Mock context with missing runId - global.context = { - payload: { - repository: { - html_url: "https://github.com/test-owner/test-repo", - }, - }, - }; - - const metadata = getWorkflowMetadata("test-owner", "test-repo"); - - expect(metadata.runId).toBe(0); - expect(metadata.runUrl).toBe("https://github.com/test-owner/test-repo/actions/runs/0"); - }); -}); - -describe("buildWorkflowRunUrl", () => { - it("should build run URL from context.serverUrl and explicit workflowRepo", () => { - const ctx = { serverUrl: "https://github.com", runId: 42000 }; - const url = buildWorkflowRunUrl(ctx, { owner: "wf-owner", repo: "wf-repo" }); - expect(url).toBe("https://github.com/wf-owner/wf-repo/actions/runs/42000"); - }); - - it("should fall back to GITHUB_SERVER_URL when context.serverUrl is absent", () => { - const originalEnv = process.env.GITHUB_SERVER_URL; - process.env.GITHUB_SERVER_URL = "https://ghes.example.com"; - const ctx = { runId: 99 }; - const url = buildWorkflowRunUrl(ctx, { owner: "ent-owner", repo: "ent-repo" }); - expect(url).toBe("https://ghes.example.com/ent-owner/ent-repo/actions/runs/99"); - if (originalEnv === undefined) { - delete process.env.GITHUB_SERVER_URL; - } else { - process.env.GITHUB_SERVER_URL = originalEnv; - } - }); - - it("should use the workflowRepo, not a cross-repo target", () => { - // Simulates the cross-repo case: context.repo is the target but workflowRepo is the workflow owner - const ctx = { serverUrl: "https://github.com", runId: 7777, repo: { owner: "cross-owner", repo: "cross-repo" } }; - const workflowRepo = { owner: "wf-owner", repo: "wf-repo" }; - const url = buildWorkflowRunUrl(ctx, workflowRepo); - expect(url).toBe("https://github.com/wf-owner/wf-repo/actions/runs/7777"); - expect(url).not.toContain("cross-owner"); - expect(url).not.toContain("cross-repo"); - }); -}); diff --git a/setup/js/write_large_content_to_file.test.cjs b/setup/js/write_large_content_to_file.test.cjs deleted file mode 100644 index 02ed106..0000000 --- a/setup/js/write_large_content_to_file.test.cjs +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import fs from "fs"; -import path from "path"; - -describe("writeLargeContentToFile", () => { - const testDir = "/tmp/gh-aw/safeoutputs"; - - beforeEach(() => { - // Clean up test directory before each test - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true }); - } - }); - - afterEach(() => { - // Clean up test directory after each test - if (fs.existsSync(testDir)) { - fs.rmSync(testDir, { recursive: true }); - } - }); - - it("should create directory if it doesn't exist", async () => { - const { writeLargeContentToFile } = await import("./write_large_content_to_file.cjs"); - - expect(fs.existsSync(testDir)).toBe(false); - - const content = JSON.stringify({ test: "data" }); - writeLargeContentToFile(content); - - expect(fs.existsSync(testDir)).toBe(true); - }); - - it("should write content to file with hash-based filename", async () => { - const { writeLargeContentToFile } = await import("./write_large_content_to_file.cjs"); - - const content = JSON.stringify({ test: "data" }); - const result = writeLargeContentToFile(content); - - expect(result).toHaveProperty("filename"); - expect(result.filename).toMatch(/^[a-f0-9]{64}\.json$/); - - const filepath = path.join(testDir, result.filename); - expect(fs.existsSync(filepath)).toBe(true); - - const written = fs.readFileSync(filepath, "utf8"); - expect(written).toBe(content); - }); - - it("should return schema description", async () => { - const { writeLargeContentToFile } = await import("./write_large_content_to_file.cjs"); - - const content = JSON.stringify({ id: 1, name: "test", value: 10 }); - const result = writeLargeContentToFile(content); - - expect(result).toHaveProperty("description"); - expect(result.description).toBe("{id, name, value}"); - }); - - it("should use .json extension", async () => { - const { writeLargeContentToFile } = await import("./write_large_content_to_file.cjs"); - - const content = JSON.stringify([1, 2, 3]); - const result = writeLargeContentToFile(content); - - expect(result.filename).toMatch(/\.json$/); - }); - - it("should generate consistent hash for same content", async () => { - const { writeLargeContentToFile } = await import("./write_large_content_to_file.cjs"); - - const content = JSON.stringify({ test: "data" }); - const result1 = writeLargeContentToFile(content); - const result2 = writeLargeContentToFile(content); - - expect(result1.filename).toBe(result2.filename); - }); - - it("should generate different hash for different content", async () => { - const { writeLargeContentToFile } = await import("./write_large_content_to_file.cjs"); - - const content1 = JSON.stringify({ test: "data1" }); - const content2 = JSON.stringify({ test: "data2" }); - - const result1 = writeLargeContentToFile(content1); - const result2 = writeLargeContentToFile(content2); - - expect(result1.filename).not.toBe(result2.filename); - }); - - it("should handle arrays", async () => { - const { writeLargeContentToFile } = await import("./write_large_content_to_file.cjs"); - - const content = JSON.stringify([{ id: 1 }, { id: 2 }]); - const result = writeLargeContentToFile(content); - - expect(result.description).toBe("[{id}] (2 items)"); - }); - - it("should handle large content", async () => { - const { writeLargeContentToFile } = await import("./write_large_content_to_file.cjs"); - - const largeObj = {}; - for (let i = 0; i < 1000; i++) { - largeObj[`key${i}`] = `value${i}`; - } - const content = JSON.stringify(largeObj); - - const result = writeLargeContentToFile(content); - - expect(result).toHaveProperty("filename"); - expect(result).toHaveProperty("description"); - - const filepath = path.join(testDir, result.filename); - const written = fs.readFileSync(filepath, "utf8"); - expect(written).toBe(content); - }); -});