Files
claude-code-action/test/permissions.test.ts
T
claude[bot]andClaude Sonnet 4.6 ec394266fc chore: merge 7 upstream commits from anthropics/claude-code-action
Conflicts resolved:
- docs/usage.md: kept fork's `platform` row, added upstream's new
  `classify_inline_comments` row, updated `allowed_bots` description
  with upstream's security warning
- src/create-prompt/index.ts: kept fork's DISALLOWED_TOOLS export
  (consumed by run.ts) vs upstream's dead-code comment
- src/modes/tag/index.ts: merged both fork's userAllowedTools and
  upstream's gitPushWrapper variable (both needed by shared code)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 08:22:39 +00:00

468 lines
14 KiB
TypeScript

import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test";
import * as core from "@actions/core";
import { checkWritePermissions } from "../src/github/validation/permissions";
import type { ParsedGitHubContext } from "../src/github/context";
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants";
import { _resetPlatformCache } from "../src/platform/detector";
describe("checkWritePermissions", () => {
let coreInfoSpy: any;
let coreWarningSpy: any;
let coreErrorSpy: any;
beforeEach(() => {
// Spy on core methods
coreInfoSpy = spyOn(core, "info").mockImplementation(() => {});
coreWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
coreErrorSpy = spyOn(core, "error").mockImplementation(() => {});
});
afterEach(() => {
coreInfoSpy.mockRestore();
coreWarningSpy.mockRestore();
coreErrorSpy.mockRestore();
});
const createMockOctokit = (permission: string) => {
return {
repos: {
getCollaboratorPermissionLevel: async () => ({
data: { permission },
}),
},
} as any;
};
const createContext = (): ParsedGitHubContext => ({
runId: "1234567890",
eventName: "issue_comment",
eventAction: "created",
repository: {
full_name: "test-owner/test-repo",
owner: "test-owner",
repo: "test-repo",
},
actor: "test-user",
payload: {
action: "created",
issue: {
number: 1,
title: "Test Issue",
body: "Test body",
user: { login: "test-user" },
},
comment: {
id: 123,
body: "@claude test",
user: { login: "test-user" },
html_url:
"https://github.com/test-owner/test-repo/issues/1#issuecomment-123",
},
} as any,
entityNumber: 1,
isPR: false,
inputs: {
prompt: "",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
branchPrefix: "claude/",
useStickyComment: false,
classifyInlineComments: true,
useCommitSigning: false,
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN,
botEmail: "",
allowedBots: "",
allowedNonWriteUsers: "",
trackProgress: false,
includeFixLinks: true,
includeCommentsByActor: "",
excludeCommentsByActor: "",
},
});
test("should return true for admin permissions", async () => {
const mockOctokit = createMockOctokit("admin");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
expect(coreInfoSpy).toHaveBeenCalledWith(
"Checking permissions for actor: test-user",
);
expect(coreInfoSpy).toHaveBeenCalledWith(
"Permission level retrieved: admin",
);
expect(coreInfoSpy).toHaveBeenCalledWith("Actor has write access: admin");
});
test("should return true for write permissions", async () => {
const mockOctokit = createMockOctokit("write");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
expect(coreInfoSpy).toHaveBeenCalledWith("Actor has write access: write");
});
test("should return false for read permissions", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: read",
);
});
test("should return false for none permissions", async () => {
const mockOctokit = createMockOctokit("none");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: none",
);
});
test("should return true for bot user", async () => {
const mockOctokit = createMockOctokit("none");
const context = createContext();
context.actor = "test-bot[bot]";
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
});
test("should throw error when permission check fails", async () => {
const error = new Error("API error");
const mockOctokit = {
repos: {
getCollaboratorPermissionLevel: async () => {
throw error;
},
},
} as any;
const context = createContext();
await expect(checkWritePermissions(mockOctokit, context)).rejects.toThrow(
"Failed to check permissions for test-user: Error: API error",
);
expect(coreErrorSpy).toHaveBeenCalledWith(
"Failed to check permissions: Error: API error",
);
});
test("should call API with correct parameters", async () => {
let capturedParams: any;
const mockOctokit = {
repos: {
getCollaboratorPermissionLevel: async (params: any) => {
capturedParams = params;
return { data: { permission: "write" } };
},
},
} as any;
const context = createContext();
await checkWritePermissions(mockOctokit, context);
expect(capturedParams).toEqual({
owner: "test-owner",
repo: "test-repo",
username: "test-user",
});
});
describe("allowed_non_write_users bypass", () => {
test("should bypass permission check for specific user when github_token provided", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
"test-user,other-user",
true,
);
expect(result).toBe(true);
expect(coreWarningSpy).toHaveBeenCalledWith(
"⚠️ SECURITY WARNING: Bypassing write permission check for test-user due to allowed_non_write_users configuration. This should only be used for workflows with very limited permissions.",
);
});
test("should bypass permission check for all users with wildcard", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
"*",
true,
);
expect(result).toBe(true);
expect(coreWarningSpy).toHaveBeenCalledWith(
"⚠️ SECURITY WARNING: Bypassing write permission check for test-user due to allowed_non_write_users='*'. This should only be used for workflows with very limited permissions.",
);
});
test("should NOT bypass permission check when user not in allowed list", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
"other-user,another-user",
true,
);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: read",
);
});
test("should NOT bypass permission check when github_token not provided", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
"test-user",
false,
);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: read",
);
});
test("should NOT bypass permission check when allowed_non_write_users is empty", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
"",
true,
);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: read",
);
});
test("should handle whitespace in allowed_non_write_users list", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
" test-user , other-user ",
true,
);
expect(result).toBe(true);
expect(coreWarningSpy).toHaveBeenCalledWith(
"⚠️ SECURITY WARNING: Bypassing write permission check for test-user due to allowed_non_write_users configuration. This should only be used for workflows with very limited permissions.",
);
});
test("should bypass for bot users even when allowed_non_write_users is set", async () => {
const mockOctokit = createMockOctokit("none");
const context = createContext();
context.actor = "test-bot[bot]";
const result = await checkWritePermissions(
mockOctokit,
context,
"some-user",
true,
);
expect(result).toBe(true);
expect(coreInfoSpy).toHaveBeenCalledWith(
"Actor is a GitHub App: test-bot[bot]",
);
});
});
describe("Forgejo permission check", () => {
let savedPlatform: string | undefined;
let savedForgejo: string | undefined;
let savedServerUrl: string | undefined;
beforeEach(() => {
_resetPlatformCache();
savedPlatform = process.env.PLATFORM;
savedForgejo = process.env.FORGEJO;
savedServerUrl = process.env.GITHUB_SERVER_URL;
process.env.PLATFORM = "forgejo";
// Ensure cross-validation doesn't reject PLATFORM=forgejo
process.env.GITHUB_SERVER_URL = "https://git.example.com";
});
afterEach(() => {
if (savedPlatform !== undefined) process.env.PLATFORM = savedPlatform;
else delete process.env.PLATFORM;
if (savedForgejo !== undefined) process.env.FORGEJO = savedForgejo;
else delete process.env.FORGEJO;
if (savedServerUrl !== undefined)
process.env.GITHUB_SERVER_URL = savedServerUrl;
else delete process.env.GITHUB_SERVER_URL;
_resetPlatformCache();
});
// Helper: Forgejo octokit where collaborator API works (actor-level check)
const createForgejoActorOctokit = (permission: string) => {
return {
repos: {
getCollaboratorPermissionLevel: async () => ({
data: { permission },
}),
get: async () => {
throw new Error("Should not fall back to repos.get");
},
},
} as any;
};
// Helper: Forgejo octokit where collaborator API fails (falls back to token-level)
const createForgejoFallbackOctokit = (permissions: {
admin?: boolean;
push?: boolean;
pull?: boolean;
}) => {
return {
repos: {
getCollaboratorPermissionLevel: async () => {
throw new Error("403 Forbidden");
},
get: async () => ({
data: { permissions },
}),
},
} as any;
};
test("should use actor-level permission check on Forgejo (write)", async () => {
const mockOctokit = createForgejoActorOctokit("write");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
expect(coreInfoSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Forgejo actor 'test-user' permission level: write",
),
);
});
test("should use actor-level permission check on Forgejo (admin)", async () => {
const mockOctokit = createForgejoActorOctokit("admin");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
});
test("should reject actor with read-only permission on Forgejo", async () => {
const mockOctokit = createForgejoActorOctokit("read");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("insufficient permissions: read"),
);
});
test("should fall back to token-level check when collaborator API fails", async () => {
const mockOctokit = createForgejoFallbackOctokit({
admin: false,
push: true,
pull: true,
});
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
expect(coreWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("Falling back to token-level permission check"),
);
expect(coreInfoSpy).toHaveBeenCalledWith(
expect.stringContaining("token-level fallback"),
);
});
test("should return false on fallback when token has only pull access", async () => {
const mockOctokit = createForgejoFallbackOctokit({
admin: false,
push: false,
pull: true,
});
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(false);
});
test("should return false on fallback when permissions object is missing", async () => {
const mockOctokit = {
repos: {
getCollaboratorPermissionLevel: async () => {
throw new Error("403 Forbidden");
},
get: async () => ({ data: {} }),
},
} as any;
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("did not return permissions"),
);
});
test("should still bypass for allowed_non_write_users on Forgejo", async () => {
const mockOctokit = createForgejoActorOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
"test-user",
true,
);
expect(result).toBe(true);
});
});
});