- Add self-mention/bot loop prevention in actor.ts (blocks actor matching bot_name) - Switch Forgejo permission check to actor-level via getCollaboratorPermissionLevel with fallback to token-level repos.get() when collaborator API returns 403 - Close empty-body PR review comment trigger bypass (now returns false instead of true) - Add platform detection cross-validation (reject PLATFORM=forgejo on github.com) - Document URL builder defense-in-depth rationale in comment-logic.ts - Update/add tests across 6 test files (actor, permissions, trigger, platform, mcp, m7) Progress: 50% complete (3/6 milestones) — next: M4 test coverage review Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
210 lines
6.9 KiB
TypeScript
210 lines
6.9 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
import { checkHumanActor } from "../src/github/validation/actor";
|
|
import { _resetPlatformCache } from "../src/platform/detector";
|
|
import type { Octokit } from "@octokit/rest";
|
|
import { createMockContext } from "./mockContext";
|
|
|
|
function createMockOctokit(userType: string): Octokit {
|
|
return {
|
|
users: {
|
|
getByUsername: async () => ({
|
|
data: {
|
|
type: userType,
|
|
},
|
|
}),
|
|
},
|
|
} as unknown as Octokit;
|
|
}
|
|
|
|
describe("checkHumanActor", () => {
|
|
test("should pass for human actor", async () => {
|
|
const mockOctokit = createMockOctokit("User");
|
|
const context = createMockContext();
|
|
context.actor = "human-user";
|
|
|
|
await expect(
|
|
checkHumanActor(mockOctokit, context),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("should throw error for bot actor when not allowed", async () => {
|
|
const mockOctokit = createMockOctokit("Bot");
|
|
const context = createMockContext();
|
|
context.actor = "test-bot[bot]";
|
|
context.inputs.allowedBots = "";
|
|
|
|
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
|
"Workflow initiated by non-human actor: test-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
|
);
|
|
});
|
|
|
|
test("should pass for bot actor when all bots allowed", async () => {
|
|
const mockOctokit = createMockOctokit("Bot");
|
|
const context = createMockContext();
|
|
context.actor = "test-bot[bot]";
|
|
context.inputs.allowedBots = "*";
|
|
|
|
await expect(
|
|
checkHumanActor(mockOctokit, context),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("should pass for specific bot when in allowed list", async () => {
|
|
const mockOctokit = createMockOctokit("Bot");
|
|
const context = createMockContext();
|
|
context.actor = "dependabot[bot]";
|
|
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
|
|
|
|
await expect(
|
|
checkHumanActor(mockOctokit, context),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("should pass for specific bot when in allowed list (without [bot])", async () => {
|
|
const mockOctokit = createMockOctokit("Bot");
|
|
const context = createMockContext();
|
|
context.actor = "dependabot[bot]";
|
|
context.inputs.allowedBots = "dependabot,renovate";
|
|
|
|
await expect(
|
|
checkHumanActor(mockOctokit, context),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("should throw error for bot not in allowed list", async () => {
|
|
const mockOctokit = createMockOctokit("Bot");
|
|
const context = createMockContext();
|
|
context.actor = "other-bot[bot]";
|
|
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
|
|
|
|
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
|
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
|
);
|
|
});
|
|
|
|
test("should throw error for bot not in allowed list (without [bot])", async () => {
|
|
const mockOctokit = createMockOctokit("Bot");
|
|
const context = createMockContext();
|
|
context.actor = "other-bot[bot]";
|
|
context.inputs.allowedBots = "dependabot,renovate";
|
|
|
|
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
|
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
|
);
|
|
});
|
|
|
|
test("should treat undefined actor type as human (GitHub compatibility)", async () => {
|
|
// Edge case: GitHub API returns no `type` field
|
|
const mockOctokit = {
|
|
users: {
|
|
getByUsername: async () => ({
|
|
data: {},
|
|
}),
|
|
},
|
|
} as unknown as Octokit;
|
|
const context = createMockContext();
|
|
context.actor = "unknown-user";
|
|
|
|
await expect(
|
|
checkHumanActor(mockOctokit, context),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
describe("Forgejo platform", () => {
|
|
let savedPlatform: string | undefined;
|
|
let savedServerUrl: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
_resetPlatformCache();
|
|
savedPlatform = process.env.PLATFORM;
|
|
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 (savedServerUrl !== undefined)
|
|
process.env.GITHUB_SERVER_URL = savedServerUrl;
|
|
else delete process.env.GITHUB_SERVER_URL;
|
|
_resetPlatformCache();
|
|
});
|
|
|
|
test("should skip GitHub API actor check on Forgejo for regular users", async () => {
|
|
let apiCalled = false;
|
|
const mockOctokit = {
|
|
users: {
|
|
getByUsername: async () => {
|
|
apiCalled = true;
|
|
return { data: {} };
|
|
},
|
|
},
|
|
} as unknown as Octokit;
|
|
const context = createMockContext();
|
|
context.actor = "forgejo-user";
|
|
|
|
await expect(
|
|
checkHumanActor(mockOctokit, context),
|
|
).resolves.toBeUndefined();
|
|
expect(apiCalled).toBe(false);
|
|
});
|
|
|
|
test("should skip actor check for bot-like names that don't match bot_name", async () => {
|
|
let apiCalled = false;
|
|
const mockOctokit = {
|
|
users: {
|
|
getByUsername: async () => {
|
|
apiCalled = true;
|
|
return { data: { type: "Bot" } };
|
|
},
|
|
},
|
|
} as unknown as Octokit;
|
|
const context = createMockContext();
|
|
context.actor = "some-bot[bot]";
|
|
context.inputs.allowedBots = "";
|
|
|
|
await expect(
|
|
checkHumanActor(mockOctokit, context),
|
|
).resolves.toBeUndefined();
|
|
expect(apiCalled).toBe(false);
|
|
});
|
|
|
|
test("should block self-trigger when actor matches bot_name (with [bot] suffix)", async () => {
|
|
const mockOctokit = createMockOctokit("User");
|
|
const context = createMockContext();
|
|
// Default botName is "claude[bot]" from CLAUDE_BOT_LOGIN
|
|
context.actor = "claude[bot]";
|
|
|
|
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
|
"Blocking self-trigger on Forgejo",
|
|
);
|
|
});
|
|
|
|
test("should block self-trigger when actor matches bot_name without [bot] suffix", async () => {
|
|
const mockOctokit = createMockOctokit("User");
|
|
const context = createMockContext();
|
|
context.inputs.botName = "claude[bot]";
|
|
context.actor = "claude";
|
|
|
|
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
|
"Blocking self-trigger on Forgejo",
|
|
);
|
|
});
|
|
|
|
test("should block self-trigger with custom bot_name", async () => {
|
|
const mockOctokit = createMockOctokit("User");
|
|
const context = createMockContext();
|
|
context.inputs.botName = "my-assistant";
|
|
context.actor = "my-assistant";
|
|
|
|
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
|
"Blocking self-trigger on Forgejo",
|
|
);
|
|
});
|
|
});
|
|
});
|