diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml new file mode 100644 index 0000000..051bb11 --- /dev/null +++ b/.github/workflows/integration.yaml @@ -0,0 +1,17 @@ +name: Integration + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + - run: bun test diff --git a/handlers/check-weeks.js b/handlers/check-weeks.js index 857b590..3f9b6ab 100644 --- a/handlers/check-weeks.js +++ b/handlers/check-weeks.js @@ -6,6 +6,7 @@ import { generateGitHubAppToken, getGitHubHeaders } from "../utils/github.js"; import { corsResponse, errorResponse } from "../utils/cors.js"; import { handleWeekComment } from "../utils/prWeeks.js"; import { validateOrganization, hasMaintenanceLabel } from "../utils/validation.js"; +import { ALLOWED_REPO } from "../utils/constants.js"; /** * 모든 Open PR의 Week 설정을 검사하고 자동으로 댓글 작성/삭제 @@ -29,6 +30,11 @@ export async function checkWeeks(request, env) { return errorResponse(`Unauthorized organization: ${repoOwner}`, 403); } + // 허용된 repository만 처리 + if (repo_name !== ALLOWED_REPO) { + return errorResponse(`Unauthorized repository: ${repo_name}`, 403); + } + // GitHub App Token 생성 const appToken = await generateGitHubAppToken(env); diff --git a/handlers/check-weeks.test.js b/handlers/check-weeks.test.js new file mode 100644 index 0000000..cde4738 --- /dev/null +++ b/handlers/check-weeks.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "bun:test"; + +vi.mock("../utils/github.js", () => ({ + generateGitHubAppToken: vi.fn().mockResolvedValue("fake-token"), + getGitHubHeaders: vi.fn().mockReturnValue({ + Authorization: "token fake-token", + }), +})); + +vi.mock("../utils/prWeeks.js", () => ({ + handleWeekComment: vi.fn().mockResolvedValue("Week 1"), +})); + +import { checkWeeks } from "./check-weeks.js"; +import { generateGitHubAppToken } from "../utils/github.js"; + +function makeRequest(body) { + return new Request("https://example.com/check-weeks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const env = {}; + +describe("check-weeks repo filtering", () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + }); + + it("returns 403 for non-DaleStudy organization", async () => { + const request = makeRequest({ + repo_owner: "OtherOrg", + repo_name: "leetcode-study", + }); + + const response = await checkWeeks(request, env); + expect(response.status).toBe(403); + + const body = await response.json(); + expect(body.error).toContain("Unauthorized organization"); + }); + + it("returns 403 for non-leetcode-study repo_name", async () => { + const request = makeRequest({ + repo_owner: "DaleStudy", + repo_name: "daleui", + }); + + const response = await checkWeeks(request, env); + expect(response.status).toBe(403); + + const body = await response.json(); + expect(body.error).toContain("Unauthorized repository"); + + expect(generateGitHubAppToken).not.toHaveBeenCalled(); + }); + + it("processes leetcode-study repo_name successfully", async () => { + const request = makeRequest({ + repo_owner: "DaleStudy", + repo_name: "leetcode-study", + }); + + const response = await checkWeeks(request, env); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.success).toBe(true); + }); + + it("returns 400 when repo_name is missing", async () => { + const request = makeRequest({ + repo_owner: "DaleStudy", + }); + + const response = await checkWeeks(request, env); + expect(response.status).toBe(400); + + const body = await response.json(); + expect(body.error).toContain("repo_name"); + }); + + it("defaults repo_owner to DaleStudy when omitted", async () => { + const request = makeRequest({ + repo_name: "leetcode-study", + }); + + const response = await checkWeeks(request, env); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.success).toBe(true); + }); +}); diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 1b1ebdf..6745115 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -125,6 +125,12 @@ async function handleProjectsV2ItemEvent(payload, env) { const { number: prNumber, owner: repoOwner, repo: repoName } = prInfo; + // 허용된 repository만 처리 (projects_v2_item 이벤트는 payload에 repository가 없어 상위 필터를 우회함) + if (repoName !== ALLOWED_REPO) { + console.log(`Ignoring projects_v2_item for repository: ${repoName}`); + return corsResponse({ message: `Ignored: ${repoName}` }); + } + // PR 상태 확인 (closed PR, maintenance 라벨 예외) const prResponse = await fetch( `https://api.github.com/repos/${repoOwner}/${repoName}/pulls/${prNumber}`, diff --git a/handlers/webhooks.test.js b/handlers/webhooks.test.js new file mode 100644 index 0000000..d7fe355 --- /dev/null +++ b/handlers/webhooks.test.js @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach } from "bun:test"; + +vi.mock("../utils/github.js", () => ({ + generateGitHubAppToken: vi.fn().mockResolvedValue("fake-token"), + getPRInfoFromNodeId: vi.fn(), + getGitHubHeaders: vi.fn().mockReturnValue({ + Authorization: "token fake-token", + }), +})); + +vi.mock("../utils/prWeeks.js", () => ({ + ensureWarningComment: vi.fn().mockResolvedValue(false), + removeWarningComment: vi.fn().mockResolvedValue(false), + handleWeekComment: vi.fn().mockResolvedValue("Week 1"), +})); + +vi.mock("../utils/prReview.js", () => ({ + performAIReview: vi.fn(), + addReactionToComment: vi.fn(), +})); + +vi.mock("../utils/prActions.js", () => ({ + hasApprovedReview: vi.fn(), + safeJson: vi.fn(), +})); + +vi.mock("./tag-patterns.js", () => ({ + tagPatterns: vi.fn(), +})); + +vi.mock("./learning-status.js", () => ({ + postLearningStatus: vi.fn(), +})); + +import { handleWebhook } from "./webhooks.js"; +import { getPRInfoFromNodeId } from "../utils/github.js"; +import { + ensureWarningComment, + removeWarningComment, + handleWeekComment, +} from "../utils/prWeeks.js"; + +function makeRequest(eventType, payload) { + return new Request("https://example.com/webhooks", { + method: "POST", + headers: { "X-GitHub-Event": eventType }, + body: JSON.stringify(payload), + }); +} + +const env = {}; + +describe("webhook repo filtering", () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ state: "open", labels: [], draft: false }), + }); + }); + + describe("top-level filter (payload.repository)", () => { + it("ignores immediately when payload.repository.name is not leetcode-study", async () => { + const request = makeRequest("pull_request", { + action: "opened", + organization: { login: "DaleStudy" }, + repository: { name: "daleui", owner: { login: "DaleStudy" } }, + pull_request: { number: 1, labels: [], head: { sha: "abc" } }, + }); + + const response = await handleWebhook(request, env); + const body = await response.json(); + + expect(body.message).toBe("Ignored: daleui"); + }); + + it("passes when payload.repository.name is leetcode-study", async () => { + const request = makeRequest("pull_request", { + action: "synchronize", + organization: { login: "DaleStudy" }, + repository: { + name: "leetcode-study", + owner: { login: "DaleStudy" }, + }, + pull_request: { + number: 1, + labels: [], + head: { sha: "abc" }, + user: { login: "testuser" }, + }, + }); + + const response = await handleWebhook(request, env); + const body = await response.json(); + + expect(body.message).toBe("Processed"); + }); + }); + + describe("projects_v2_item event repo filtering", () => { + const basePayload = { + action: "edited", + organization: { login: "DaleStudy" }, + projects_v2_item: { + content_type: "PullRequest", + content_node_id: "PR_node123", + }, + changes: { + field_value: { + field_name: "Week", + to: { title: "Week 1" }, + }, + }, + }; + + it("ignores when GraphQL lookup returns a non-leetcode-study repo", async () => { + getPRInfoFromNodeId.mockResolvedValue({ + number: 962, + owner: "DaleStudy", + repo: "daleui", + }); + + const request = makeRequest("projects_v2_item", basePayload); + const response = await handleWebhook(request, env); + const body = await response.json(); + + expect(body.message).toBe("Ignored: daleui"); + expect(ensureWarningComment).not.toHaveBeenCalled(); + expect(removeWarningComment).not.toHaveBeenCalled(); + }); + + it("processes normally when GraphQL lookup returns leetcode-study", async () => { + getPRInfoFromNodeId.mockResolvedValue({ + number: 100, + owner: "DaleStudy", + repo: "leetcode-study", + }); + + const request = makeRequest("projects_v2_item", basePayload); + const response = await handleWebhook(request, env); + const body = await response.json(); + + expect(body.message).toBe("Processed"); + }); + + it("ignores non-leetcode-study repo on deleted action", async () => { + getPRInfoFromNodeId.mockResolvedValue({ + number: 962, + owner: "DaleStudy", + repo: "daleui", + }); + + const request = makeRequest("projects_v2_item", { + ...basePayload, + action: "deleted", + }); + const response = await handleWebhook(request, env); + const body = await response.json(); + + expect(body.message).toBe("Ignored: daleui"); + expect(ensureWarningComment).not.toHaveBeenCalled(); + }); + + it("ignores non-leetcode-study repo on created action", async () => { + getPRInfoFromNodeId.mockResolvedValue({ + number: 962, + owner: "DaleStudy", + repo: "daleui", + }); + + const request = makeRequest("projects_v2_item", { + ...basePayload, + action: "created", + }); + const response = await handleWebhook(request, env); + const body = await response.json(); + + expect(body.message).toBe("Ignored: daleui"); + expect(handleWeekComment).not.toHaveBeenCalled(); + }); + }); + + describe("organization filter", () => { + it("ignores non-DaleStudy organization", async () => { + const request = makeRequest("pull_request", { + action: "opened", + organization: { login: "OtherOrg" }, + repository: { + name: "leetcode-study", + owner: { login: "OtherOrg" }, + }, + pull_request: { number: 1, labels: [], head: { sha: "abc" } }, + }); + + const response = await handleWebhook(request, env); + const body = await response.json(); + + expect(body.message).toBe("Ignored: not DaleStudy organization"); + }); + + it("ignores when organization field is missing", async () => { + const request = makeRequest("pull_request", { + action: "opened", + repository: { + name: "leetcode-study", + owner: { login: "DaleStudy" }, + }, + pull_request: { number: 1, labels: [], head: { sha: "abc" } }, + }); + + const response = await handleWebhook(request, env); + const body = await response.json(); + + expect(body.message).toBe("Ignored: not DaleStudy organization"); + }); + }); + + describe("event type filter", () => { + it("ignores unsupported event types", async () => { + const request = makeRequest("push", { + organization: { login: "DaleStudy" }, + repository: { + name: "leetcode-study", + owner: { login: "DaleStudy" }, + }, + }); + + const response = await handleWebhook(request, env); + const body = await response.json(); + + expect(body.message).toBe("Ignored: push"); + }); + }); +});