| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | |
| 3 | const securityMocks = vi.hoisted(() => ({ |
| 4 | agentChat: vi.fn(), |
| 5 | fetchFeed: vi.fn(), |
| 6 | getAgentEnv: vi.fn(), |
| 7 | validateSession: vi.fn(), |
| 8 | })); |
| 9 | |
| 10 | vi.mock("@/lib/community-agent", async (importOriginal) => { |
| 11 | const actual = await importOriginal<typeof import("./community-agent")>(); |
| 12 | return { |
| 13 | ...actual, |
| 14 | agentChat: securityMocks.agentChat, |
| 15 | getAgentEnv: securityMocks.getAgentEnv, |
| 16 | validateSession: securityMocks.validateSession, |
| 17 | }; |
| 18 | }); |
| 19 | |
| 20 | vi.mock("@/lib/github", async (importOriginal) => { |
| 21 | const actual = await importOriginal<typeof import("./github")>(); |
| 22 | return { |
| 23 | ...actual, |
| 24 | fetchFeed: securityMocks.fetchFeed, |
| 25 | }; |
| 26 | }); |
| 27 | |
| 28 | import { POST as adminPost } from "../app/api/admin/post/route"; |
| 29 | import { GET as publicFeed } from "../app/api/github/feed/route"; |
| 30 | import { runPrReview, runTriage } from "./community-agent-tasks"; |
| 31 | |
| 32 | class FakeKv { |
| 33 | readonly values = new Map<string, string>(); |
| 34 | readonly reads: string[] = []; |
| 35 | readonly deleted: string[] = []; |
| 36 | |
| 37 | async get(key: string): Promise<string | null> { |
| 38 | this.reads.push(key); |
| 39 | return this.values.get(key) ?? null; |
| 40 | } |
| 41 | |
| 42 | async put(key: string, value: string): Promise<void> { |
| 43 | this.values.set(key, value); |
| 44 | } |
| 45 | |
| 46 | async list(options?: { prefix?: string; limit?: number }): Promise<{ keys: { name: string }[] }> { |
| 47 | const prefix = options?.prefix ?? ""; |
| 48 | const limit = options?.limit ?? Number.POSITIVE_INFINITY; |
| 49 | return { |
| 50 | keys: [...this.values.keys()] |
| 51 | .filter((key) => key.startsWith(prefix)) |
| 52 | .slice(0, limit) |
| 53 | .map((name) => ({ name })), |
| 54 | }; |
| 55 | } |
| 56 | |
| 57 | async delete(key: string): Promise<void> { |
| 58 | this.deleted.push(key); |
| 59 | this.values.delete(key); |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | function jsonResponse(value: unknown): Response { |
| 64 | return new Response(JSON.stringify(value), { |
| 65 | status: 200, |
| 66 | headers: { "content-type": "application/json" }, |
| 67 | }); |
| 68 | } |
| 69 | |
| 70 | function inputUrl(input: string | URL | Request): string { |
| 71 | if (typeof input === "string") return input; |
| 72 | return input instanceof URL ? input.toString() : input.url; |
| 73 | } |
| 74 | |
| 75 | beforeEach(() => { |
| 76 | securityMocks.agentChat.mockReset(); |
| 77 | securityMocks.fetchFeed.mockReset(); |
| 78 | securityMocks.getAgentEnv.mockReset(); |
| 79 | securityMocks.validateSession.mockReset(); |
| 80 | securityMocks.validateSession.mockResolvedValue(true); |
| 81 | }); |
| 82 | |
| 83 | afterEach(() => { |
| 84 | vi.unstubAllEnvs(); |
| 85 | vi.unstubAllGlobals(); |
| 86 | }); |
| 87 | |
| 88 | describe("public security boundaries", () => { |
| 89 | it("rejects a non-draft admin key before any draft KV read or delete", async () => { |
| 90 | const kv = new FakeKv(); |
| 91 | kv.values.set("dispatch:latest", JSON.stringify({ state: "running" })); |
| 92 | securityMocks.getAgentEnv.mockResolvedValue({ |
| 93 | CURATED_KV: kv, |
| 94 | MAINTAINER_TOKEN: "configured", |
| 95 | }); |
| 96 | |
| 97 | const response = await adminPost(new Request("https://codewhale.net/api/admin/post", { |
| 98 | method: "POST", |
| 99 | headers: { |
| 100 | "content-type": "application/json", |
| 101 | cookie: "mt_sid=test-session", |
| 102 | origin: "https://codewhale.net", |
| 103 | }, |
| 104 | body: JSON.stringify({ action: "discard", draftKey: "dispatch:latest" }), |
| 105 | })); |
| 106 | |
| 107 | await expect(response.json()).resolves.toEqual({ error: "invalid draftKey namespace" }); |
| 108 | expect(response.status).toBe(400); |
| 109 | expect(securityMocks.validateSession).toHaveBeenCalledOnce(); |
| 110 | expect(kv.reads).toEqual([]); |
| 111 | expect(kv.deleted).toEqual([]); |
| 112 | expect(kv.values.has("dispatch:latest")).toBe(true); |
| 113 | }); |
| 114 | |
| 115 | it("never forwards an ambient server token through the public feed route", async () => { |
| 116 | vi.stubEnv("GITHUB_TOKEN", "server-secret-must-not-cross-public-boundary"); |
| 117 | securityMocks.fetchFeed.mockResolvedValue([]); |
| 118 | |
| 119 | const response = await publicFeed(); |
| 120 | |
| 121 | expect(response.status).toBe(200); |
| 122 | await expect(response.json()).resolves.toMatchObject({ items: [] }); |
| 123 | expect(securityMocks.fetchFeed).toHaveBeenCalledExactlyOnceWith(undefined, 50); |
| 124 | }); |
| 125 | |
| 126 | it("does not make another model call for unchanged triage or PR inputs", async () => { |
| 127 | securityMocks.agentChat.mockResolvedValue({ |
| 128 | content: JSON.stringify({ bodyEn: "review", bodyZh: "审阅" }), |
| 129 | usage: { input: 10, output: 5 }, |
| 130 | }); |
| 131 | |
| 132 | const triageKv = new FakeKv(); |
| 133 | const triageFetch = vi.fn(async (input: string | URL | Request) => { |
| 134 | const url = inputUrl(input); |
| 135 | if (!url.includes("/issues?")) throw new Error(`unexpected triage URL: ${url}`); |
| 136 | return jsonResponse([{ |
| 137 | number: 42, |
| 138 | title: "Unchanged issue", |
| 139 | body: "same body", |
| 140 | updated_at: "2020-01-01T00:00:00.000Z", |
| 141 | html_url: "https://github.com/Hmbown/CodeWhale/issues/42", |
| 142 | labels: [], |
| 143 | }]); |
| 144 | }); |
| 145 | vi.stubGlobal("fetch", triageFetch); |
| 146 | |
| 147 | const triageEnv = { CURATED_KV: triageKv, DEEPSEEK_API_KEY: "test-key" }; |
| 148 | await expect(runTriage(triageEnv)).resolves.toMatchObject({ processed: 1, skipped: 0 }); |
| 149 | expect(securityMocks.agentChat).toHaveBeenCalledOnce(); |
| 150 | securityMocks.agentChat.mockClear(); |
| 151 | await expect(runTriage(triageEnv)).resolves.toMatchObject({ processed: 0, skipped: 1 }); |
| 152 | expect(securityMocks.agentChat).not.toHaveBeenCalled(); |
| 153 | expect(triageKv.values.has("draft:triage:42")).toBe(true); |
| 154 | |
| 155 | const prKv = new FakeKv(); |
| 156 | const prFetch = vi.fn(async (input: string | URL | Request) => { |
| 157 | const url = inputUrl(input); |
| 158 | if (!url.includes("/pulls?")) throw new Error(`unexpected PR URL: ${url}`); |
| 159 | return jsonResponse([{ |
| 160 | number: 84, |
| 161 | title: "Unchanged PR", |
| 162 | body: "same body", |
| 163 | updated_at: "2020-01-01T00:00:00.000Z", |
| 164 | html_url: "https://github.com/Hmbown/CodeWhale/pull/84", |
| 165 | changed_files: 3, |
| 166 | additions: 10, |
| 167 | deletions: 2, |
| 168 | user: { login: "contributor" }, |
| 169 | }]); |
| 170 | }); |
| 171 | vi.stubGlobal("fetch", prFetch); |
| 172 | |
| 173 | const prEnv = { CURATED_KV: prKv, DEEPSEEK_API_KEY: "test-key" }; |
| 174 | await expect(runPrReview(prEnv)).resolves.toMatchObject({ processed: 1, skipped: 0 }); |
| 175 | expect(securityMocks.agentChat).toHaveBeenCalledOnce(); |
| 176 | securityMocks.agentChat.mockClear(); |
| 177 | await expect(runPrReview(prEnv)).resolves.toMatchObject({ processed: 0, skipped: 1 }); |
| 178 | expect(securityMocks.agentChat).not.toHaveBeenCalled(); |
| 179 | expect(prKv.values.has("draft:pr-review:84")).toBe(true); |
| 180 | }); |
| 181 | |
| 182 | it("posts bodyZh when lang=zh and no editedBody is supplied", async () => { |
| 183 | const draft = { |
| 184 | id: "42", |
| 185 | type: "triage", |
| 186 | targetNumber: 42, |
| 187 | bodyEn: "English body", |
| 188 | bodyZh: "中文正文", |
| 189 | generatedAt: "2026-01-01T00:00:00.000Z", |
| 190 | posted: false, |
| 191 | }; |
| 192 | const kv = new FakeKv(); |
| 193 | kv.values.set("draft:triage:42", JSON.stringify(draft)); |
| 194 | |
| 195 | const capturedBodies: string[] = []; |
| 196 | const mockFetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { |
| 197 | const url = inputUrl(input); |
| 198 | if (url.includes("/issues/42/comments")) { |
| 199 | const reqBody = JSON.parse((init?.body as string) ?? "{}"); |
| 200 | capturedBodies.push(reqBody.body); |
| 201 | return new Response(JSON.stringify({ id: 1 }), { status: 201, headers: { "content-type": "application/json" } }); |
| 202 | } |
| 203 | throw new Error(`unexpected URL: ${url}`); |
| 204 | }); |
| 205 | vi.stubGlobal("fetch", mockFetch); |
| 206 | |
| 207 | securityMocks.getAgentEnv.mockResolvedValue({ |
| 208 | CURATED_KV: kv, |
| 209 | MAINTAINER_TOKEN: "configured", |
| 210 | MAINTAINER_GITHUB_PAT: "ghp_test", |
| 211 | GITHUB_REPO: "Hmbown/CodeWhale", |
| 212 | }); |
| 213 | |
| 214 | const response = await adminPost(new Request("https://codewhale.net/api/admin/post", { |
| 215 | method: "POST", |
| 216 | headers: { |
| 217 | "content-type": "application/json", |
| 218 | cookie: "mt_sid=test-session", |
| 219 | origin: "https://codewhale.net", |
| 220 | }, |
| 221 | body: JSON.stringify({ action: "post", draftKey: "draft:triage:42", lang: "zh" }), |
| 222 | })); |
| 223 | |
| 224 | await expect(response.json()).resolves.toMatchObject({ ok: true }); |
| 225 | expect(capturedBodies).toHaveLength(1); |
| 226 | expect(capturedBodies[0]).toBe("中文正文"); |
| 227 | }); |
| 228 | }); |
| 229 |