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 = "- First
- 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${tag}>`;
- 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 = "";
- 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 = "";
- 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 @ and neutralize resulting @mention", () => {
- const result = sanitizeContent("Please review @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 @ and neutralize resulting @mention", () => {
- const result = sanitizeContent("Please review @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 @ and neutralize resulting @mention", () => {
- const result = sanitizeContent("Please review @pelikhan");
- expect(result).toBe("Please review `@pelikhan`");
- });
-
- it("should decode double-encoded @ and neutralize resulting @mention", () => {
- const result = sanitizeContent("Please review @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("Hello"); // "&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 entity");
- expect(result).toBe("Malformed or 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 > entity to >", () => {
- const result = sanitizeContent("value > 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);
- });
-});