File
Blob: tests/worker/webhooks/private/project-updates.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { describe, expect, it, vi } from "vitest"; |
| 3 | |
| 4 | import { BranchName } from "@/contracts"; |
| 5 | import { readProjectDoRows } from "../../../helpers/runtime"; |
| 6 | import { registerWorkerRuntimeHooks } from "../../../helpers/worker-hooks"; |
| 7 | |
| 8 | import { createOwnedProjectContext, getProjectDetail, getWebhooks, patchProject, putWebhook } from "../helpers"; |
| 9 | |
| 10 | describe("webhook private routes", () => { |
| 11 | registerWorkerRuntimeHooks(); |
| 12 | |
| 13 | describe("project updates with configured webhooks", () => { |
| 14 | it("rejects repoUrl updates that conflict with configured webhook providers", async () => { |
| 15 | const context = await createOwnedProjectContext({ |
| 16 | user: { |
| 17 | email: "webhook-private-repo-conflict@example.com", |
| 18 | slug: "webhook-private-repo-conflict", |
| 19 | }, |
| 20 | project: { |
| 21 | projectSlug: "repo-conflict-project", |
| 22 | repoUrl: "https://gitea.example.com:8443/git/example/repo-conflict-project", |
| 23 | }, |
| 24 | }); |
| 25 | |
| 26 | await putWebhook(context.sessionId, context.project.id, "gitea", { |
| 27 | enabled: true, |
| 28 | config: { |
| 29 | instanceUrl: "https://gitea.example.com:8443/git", |
| 30 | }, |
| 31 | secret: "repo-conflict-secret", |
| 32 | }); |
| 33 | |
| 34 | const updated = await patchProject(context.sessionId, context.project.id, { |
| 35 | repoUrl: "https://github.com/example/repo-conflict-project", |
| 36 | }); |
| 37 | expect(updated.status).toBe(400); |
| 38 | expect(updated.text).toContain("project_repo_url_conflicts_with_webhook"); |
| 39 | expect(updated.text).toContain("gitea"); |
| 40 | |
| 41 | const detail = await getProjectDetail(context.sessionId, context.project.id); |
| 42 | expect(detail.status).toBe(200); |
| 43 | expect(detail.body?.project.repoUrl).toBe(context.project.repoUrl); |
| 44 | |
| 45 | const listed = await getWebhooks(context.sessionId, context.project.id); |
| 46 | expect(listed.status).toBe(200); |
| 47 | expect(listed.body?.webhooks).toHaveLength(1); |
| 48 | expect(listed.body?.webhooks[0]?.config).toEqual({ |
| 49 | instanceUrl: "https://gitea.example.com:8443/git", |
| 50 | }); |
| 51 | }); |
| 52 | |
| 53 | it("allows repoUrl updates that stay within the configured webhook instance", async () => { |
| 54 | const context = await createOwnedProjectContext({ |
| 55 | user: { |
| 56 | email: "webhook-private-repo-allowed@example.com", |
| 57 | slug: "webhook-private-repo-allowed", |
| 58 | }, |
| 59 | project: { |
| 60 | projectSlug: "repo-allowed-project", |
| 61 | repoUrl: "https://gitlab.example.com/example/repo-allowed-project", |
| 62 | }, |
| 63 | }); |
| 64 | |
| 65 | await putWebhook(context.sessionId, context.project.id, "gitlab", { |
| 66 | enabled: true, |
| 67 | config: { |
| 68 | instanceUrl: "https://gitlab.example.com", |
| 69 | }, |
| 70 | secret: "repo-allowed-secret", |
| 71 | }); |
| 72 | |
| 73 | const updated = await patchProject(context.sessionId, context.project.id, { |
| 74 | repoUrl: "https://gitlab.example.com/example/repo-allowed-project-renamed", |
| 75 | }); |
| 76 | expect(updated.status).toBe(200); |
| 77 | expect(updated.body?.project.repoUrl).toBe("https://gitlab.example.com/example/repo-allowed-project-renamed"); |
| 78 | |
| 79 | const listed = await getWebhooks(context.sessionId, context.project.id); |
| 80 | expect(listed.status).toBe(200); |
| 81 | expect(listed.body?.webhooks).toHaveLength(1); |
| 82 | expect(listed.body?.webhooks[0]?.config).toEqual({ |
| 83 | instanceUrl: "https://gitlab.example.com", |
| 84 | }); |
| 85 | }); |
| 86 | |
| 87 | it("does not bump configured webhook versions for name-only or repoToken-only updates", async () => { |
| 88 | const context = await createOwnedProjectContext({ |
| 89 | user: { |
| 90 | email: "webhook-private-non-material-update@example.com", |
| 91 | slug: "webhook-private-non-material-update", |
| 92 | }, |
| 93 | project: { |
| 94 | projectSlug: "non-material-update-project", |
| 95 | repoUrl: "https://gitlab.example.com/example/non-material-update-project", |
| 96 | }, |
| 97 | }); |
| 98 | |
| 99 | await putWebhook(context.sessionId, context.project.id, "gitlab", { |
| 100 | enabled: true, |
| 101 | config: { |
| 102 | instanceUrl: "https://gitlab.example.com", |
| 103 | }, |
| 104 | secret: "non-material-update-secret", |
| 105 | }); |
| 106 | |
| 107 | const before = await readProjectDoRows(context.project.id); |
| 108 | expect(before.webhooks).toHaveLength(1); |
| 109 | |
| 110 | const renamed = await patchProject(context.sessionId, context.project.id, { |
| 111 | name: "Renamed Project", |
| 112 | }); |
| 113 | expect(renamed.status).toBe(200); |
| 114 | expect(renamed.body?.project.name).toBe("Renamed Project"); |
| 115 | |
| 116 | const afterRename = await readProjectDoRows(context.project.id); |
| 117 | expect(afterRename.webhooks).toHaveLength(1); |
| 118 | expect(afterRename.webhooks[0]?.updatedAt).toBe(before.webhooks[0]?.updatedAt); |
| 119 | expect(afterRename.webhooks[0]?.configJson).toBe(before.webhooks[0]?.configJson); |
| 120 | expect(afterRename.webhooks[0]?.secretCiphertext).toEqual(before.webhooks[0]?.secretCiphertext); |
| 121 | expect(afterRename.webhooks[0]?.secretNonce).toEqual(before.webhooks[0]?.secretNonce); |
| 122 | |
| 123 | const retokened = await patchProject(context.sessionId, context.project.id, { |
| 124 | repoToken: "updated-repo-token", |
| 125 | }); |
| 126 | expect(retokened.status).toBe(200); |
| 127 | |
| 128 | const afterRepoToken = await readProjectDoRows(context.project.id); |
| 129 | expect(afterRepoToken.webhooks).toHaveLength(1); |
| 130 | expect(afterRepoToken.webhooks[0]?.updatedAt).toBe(before.webhooks[0]?.updatedAt); |
| 131 | expect(afterRepoToken.webhooks[0]?.configJson).toBe(before.webhooks[0]?.configJson); |
| 132 | expect(afterRepoToken.webhooks[0]?.secretCiphertext).toEqual(before.webhooks[0]?.secretCiphertext); |
| 133 | expect(afterRepoToken.webhooks[0]?.secretNonce).toEqual(before.webhooks[0]?.secretNonce); |
| 134 | |
| 135 | const detail = await getProjectDetail(context.sessionId, context.project.id); |
| 136 | expect(detail.status).toBe(200); |
| 137 | expect(detail.body?.project.name).toBe("Renamed Project"); |
| 138 | }); |
| 139 | |
| 140 | it("bumps configured webhook versions when project metadata changes", async () => { |
| 141 | const context = await createOwnedProjectContext({ |
| 142 | user: { |
| 143 | email: "webhook-private-version-bump@example.com", |
| 144 | slug: "webhook-private-version-bump", |
| 145 | }, |
| 146 | project: { |
| 147 | projectSlug: "version-bump-project", |
| 148 | repoUrl: "https://gitlab.example.com/example/version-bump-project", |
| 149 | }, |
| 150 | }); |
| 151 | |
| 152 | await putWebhook(context.sessionId, context.project.id, "gitlab", { |
| 153 | enabled: true, |
| 154 | config: { |
| 155 | instanceUrl: "https://gitlab.example.com", |
| 156 | }, |
| 157 | secret: "version-bump-secret", |
| 158 | }); |
| 159 | |
| 160 | const before = await readProjectDoRows(context.project.id); |
| 161 | expect(before.webhooks).toHaveLength(1); |
| 162 | |
| 163 | const updated = await patchProject(context.sessionId, context.project.id, { |
| 164 | repoUrl: "https://gitlab.example.com/example/version-bump-project-renamed", |
| 165 | defaultBranch: BranchName.assertDecode("develop"), |
| 166 | configPath: ".anvil.changed.yml", |
| 167 | }); |
| 168 | expect(updated.status).toBe(200); |
| 169 | |
| 170 | const after = await readProjectDoRows(context.project.id); |
| 171 | expect(after.webhooks).toHaveLength(1); |
| 172 | expect(after.webhooks[0]?.updatedAt).toBeGreaterThan(before.webhooks[0]!.updatedAt); |
| 173 | expect(after.webhooks[0]?.configJson).toBe(before.webhooks[0]?.configJson); |
| 174 | expect(after.webhooks[0]?.secretCiphertext).toEqual(before.webhooks[0]?.secretCiphertext); |
| 175 | expect(after.webhooks[0]?.secretNonce).toEqual(before.webhooks[0]?.secretNonce); |
| 176 | |
| 177 | const listed = await getWebhooks(context.sessionId, context.project.id); |
| 178 | expect(listed.status).toBe(200); |
| 179 | expect(listed.body?.webhooks).toHaveLength(1); |
| 180 | expect(listed.body?.webhooks[0]?.config).toEqual({ |
| 181 | instanceUrl: "https://gitlab.example.com", |
| 182 | }); |
| 183 | }); |
| 184 | |
| 185 | it("does not persist webhook-relevant project updates when ProjectDO config mutation fails", async () => { |
| 186 | const context = await createOwnedProjectContext({ |
| 187 | user: { |
| 188 | email: "webhook-private-touch-failure@example.com", |
| 189 | slug: "webhook-private-touch-failure", |
| 190 | }, |
| 191 | project: { |
| 192 | projectSlug: "touch-failure-project", |
| 193 | repoUrl: "https://gitlab.example.com/example/touch-failure-project", |
| 194 | }, |
| 195 | }); |
| 196 | |
| 197 | await putWebhook(context.sessionId, context.project.id, "gitlab", { |
| 198 | enabled: true, |
| 199 | config: { |
| 200 | instanceUrl: "https://gitlab.example.com", |
| 201 | }, |
| 202 | secret: "touch-failure-secret", |
| 203 | }); |
| 204 | |
| 205 | const before = await readProjectDoRows(context.project.id); |
| 206 | const originalGetByName = env.PROJECT_DO.getByName.bind(env.PROJECT_DO); |
| 207 | const getProjectStubSpy = vi.spyOn(env.PROJECT_DO, "getByName").mockImplementation((name) => { |
| 208 | if (name !== context.project.id) { |
| 209 | return originalGetByName(name); |
| 210 | } |
| 211 | |
| 212 | return { |
| 213 | updateProjectConfig: async () => { |
| 214 | throw new Error("update_project_config_failed"); |
| 215 | }, |
| 216 | } as unknown as ReturnType<typeof env.PROJECT_DO.getByName>; |
| 217 | }); |
| 218 | |
| 219 | let failedUpdate: Awaited<ReturnType<typeof patchProject>> | null = null; |
| 220 | try { |
| 221 | failedUpdate = await patchProject(context.sessionId, context.project.id, { |
| 222 | defaultBranch: BranchName.assertDecode("develop"), |
| 223 | }); |
| 224 | } finally { |
| 225 | getProjectStubSpy.mockRestore(); |
| 226 | } |
| 227 | |
| 228 | expect(failedUpdate?.status).toBe(500); |
| 229 | |
| 230 | const detail = await getProjectDetail(context.sessionId, context.project.id); |
| 231 | expect(detail.status).toBe(200); |
| 232 | expect(detail.body?.project.defaultBranch).toBe(context.project.defaultBranch); |
| 233 | |
| 234 | const after = await readProjectDoRows(context.project.id); |
| 235 | expect(after.webhooks).toHaveLength(1); |
| 236 | expect(after.webhooks[0]?.updatedAt).toBe(before.webhooks[0]?.updatedAt); |
| 237 | expect(after.webhooks[0]?.configJson).toBe(before.webhooks[0]?.configJson); |
| 238 | expect(after.webhooks[0]?.secretCiphertext).toEqual(before.webhooks[0]?.secretCiphertext); |
| 239 | expect(after.webhooks[0]?.secretNonce).toEqual(before.webhooks[0]?.secretNonce); |
| 240 | }); |
| 241 | }); |
| 242 | }); |