Skip to content
File

Blob: tests/worker/webhooks/private/project-updates.test.ts

typescript243 lines
1import { env } from "cloudflare:workers";
2import { describe, expect, it, vi } from "vitest";
3 
4import { BranchName } from "@/contracts";
5import { readProjectDoRows } from "../../../helpers/runtime";
6import { registerWorkerRuntimeHooks } from "../../../helpers/worker-hooks";
7 
8import { createOwnedProjectContext, getProjectDetail, getWebhooks, patchProject, putWebhook } from "../helpers";
9 
10describe("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});