Skip to content
File

Blob: tests/worker/webhooks/private/upsert.test.ts

typescript223 lines
1import { describe, expect, it } from "vitest";
2 
3import { readProjectDoRows } from "../../../helpers/runtime";
4import { registerWorkerRuntimeHooks } from "../../../helpers/worker-hooks";
5 
6import { createOwnedProjectContext, getProjectDetail, getWebhooks, putWebhook } from "../helpers";
7 
8describe("webhook private routes", () => {
9 registerWorkerRuntimeHooks();
10 
11 describe("upsert behavior", () => {
12 it("creates a configured provider with a generated secret and never echoes it from GET", async () => {
13 const context = await createOwnedProjectContext({
14 user: {
15 email: "webhook-private-generated@example.com",
16 slug: "webhook-private-generated",
17 },
18 project: {
19 projectSlug: "generated-secret-project",
20 repoUrl: "https://github.com/example/generated-secret-project",
21 },
22 });
23 
24 const created = await putWebhook(context.sessionId, context.project.id, "github", {
25 enabled: true,
26 });
27 expect(created.status).toBe(201);
28 expect(created.body).not.toBeNull();
29 expect(created.body?.generatedSecret).toEqual(expect.any(String));
30 expect(created.body?.webhook.provider).toBe("github");
31 expect(created.body?.webhook.config).toBeNull();
32 expect(created.body?.webhook.recentDeliveries).toEqual([]);
33 
34 const listed = await getWebhooks(context.sessionId, context.project.id);
35 expect(listed.status).toBe(200);
36 expect(listed.body?.webhooks).toHaveLength(1);
37 expect(listed.body?.webhooks[0]?.provider).toBe("github");
38 expect(listed.body?.webhooks[0]?.recentDeliveries).toEqual([]);
39 expect(listed.text).not.toContain(created.body!.generatedSecret!);
40 
41 const rows = await readProjectDoRows(context.project.id);
42 expect(rows.webhooks).toHaveLength(1);
43 expect(rows.webhookDeliveries).toHaveLength(0);
44 expect(rows.webhooks[0]?.provider).toBe("github");
45 expect(rows.webhooks[0]?.enabled).toBe(1);
46 expect(rows.webhooks[0]?.configJson).toBeNull();
47 });
48 
49 it("rejects invalid provider config and secret replacement on update without mutating durable state", async () => {
50 const githubContext = await createOwnedProjectContext({
51 user: {
52 email: "webhook-private-config@example.com",
53 slug: "webhook-private-config",
54 },
55 project: {
56 projectSlug: "config-project",
57 repoUrl: "https://github.com/example/config-project",
58 },
59 });
60 
61 const created = await putWebhook(githubContext.sessionId, githubContext.project.id, "github", {
62 enabled: true,
63 secret: "user-supplied-secret",
64 });
65 expect(created.status).toBe(201);
66 expect(created.body?.generatedSecret).toBeNull();
67 const beforeRejectedSecretUpdate = await readProjectDoRows(githubContext.project.id);
68 expect(beforeRejectedSecretUpdate.webhooks).toHaveLength(1);
69 
70 const replacedSecret = await putWebhook(githubContext.sessionId, githubContext.project.id, "github", {
71 enabled: false,
72 secret: "should-be-rejected",
73 });
74 expect(replacedSecret.status).toBe(400);
75 const afterRejectedSecretUpdate = await readProjectDoRows(githubContext.project.id);
76 expect(afterRejectedSecretUpdate.webhooks).toHaveLength(1);
77 expect(afterRejectedSecretUpdate.webhooks[0]?.enabled).toBe(beforeRejectedSecretUpdate.webhooks[0]?.enabled);
78 expect(afterRejectedSecretUpdate.webhooks[0]?.secretCiphertext).toEqual(
79 beforeRejectedSecretUpdate.webhooks[0]?.secretCiphertext,
80 );
81 expect(afterRejectedSecretUpdate.webhooks[0]?.secretNonce).toEqual(
82 beforeRejectedSecretUpdate.webhooks[0]?.secretNonce,
83 );
84 
85 const githubConfig = await putWebhook(githubContext.sessionId, githubContext.project.id, "github", {
86 enabled: true,
87 config: {
88 instanceUrl: "https://github.example.com",
89 },
90 });
91 expect(githubConfig.status).toBe(400);
92 
93 const giteaContext = await createOwnedProjectContext({
94 user: {
95 email: "webhook-private-gitea@example.com",
96 slug: "webhook-private-gitea",
97 },
98 project: {
99 projectSlug: "gitea-config-project",
100 repoUrl: "https://gitea.example.com:8443/git/example/gitea-config-project",
101 },
102 });
103 
104 const missingGiteaConfig = await putWebhook(giteaContext.sessionId, giteaContext.project.id, "gitea", {
105 enabled: true,
106 secret: "gitea-secret",
107 });
108 expect(missingGiteaConfig.status).toBe(400);
109 
110 const gitlabContext = await createOwnedProjectContext({
111 user: {
112 email: "webhook-private-gitlab@example.com",
113 slug: "webhook-private-gitlab",
114 },
115 project: {
116 projectSlug: "gitlab-config-project",
117 repoUrl: "https://gitlab.com/example/gitlab-config-project",
118 },
119 });
120 
121 const defaultGitLab = await putWebhook(gitlabContext.sessionId, gitlabContext.project.id, "gitlab", {
122 enabled: true,
123 config: null,
124 secret: "gitlab-secret",
125 });
126 expect(defaultGitLab.status).toBe(201);
127 expect(defaultGitLab.body?.generatedSecret).toBeNull();
128 expect(defaultGitLab.body?.webhook.config).toBeNull();
129 });
130 
131 it("preserves existing provider config when update omits config", async () => {
132 const context = await createOwnedProjectContext({
133 user: {
134 email: "webhook-private-preserve-config@example.com",
135 slug: "webhook-private-preserve-config",
136 },
137 project: {
138 projectSlug: "preserve-config-project",
139 repoUrl: "https://gitea.example.com:8443/git/example/preserve-config-project",
140 },
141 });
142 
143 const created = await putWebhook(context.sessionId, context.project.id, "gitea", {
144 enabled: true,
145 config: {
146 instanceUrl: "https://gitea.example.com:8443/git",
147 },
148 secret: "preserve-config-secret",
149 });
150 expect(created.status).toBe(201);
151 expect(created.body?.webhook.config).toEqual({
152 instanceUrl: "https://gitea.example.com:8443/git",
153 });
154 
155 const updated = await putWebhook(context.sessionId, context.project.id, "gitea", {
156 enabled: false,
157 });
158 expect(updated.status).toBe(200);
159 expect(updated.body?.generatedSecret).toBeNull();
160 expect(updated.body?.webhook.config).toEqual({
161 instanceUrl: "https://gitea.example.com:8443/git",
162 });
163 expect(updated.body?.webhook.enabled).toBe(false);
164 
165 const listed = await getWebhooks(context.sessionId, context.project.id);
166 expect(listed.status).toBe(200);
167 expect(listed.body?.webhooks).toHaveLength(1);
168 expect(listed.body?.webhooks[0]?.config).toEqual({
169 instanceUrl: "https://gitea.example.com:8443/git",
170 });
171 expect(listed.body?.webhooks[0]?.enabled).toBe(false);
172 
173 const rows = await readProjectDoRows(context.project.id);
174 expect(rows.webhooks).toHaveLength(1);
175 expect(rows.webhooks[0]?.configJson).toBe(JSON.stringify({ instanceUrl: "https://gitea.example.com:8443/git" }));
176 expect(rows.webhooks[0]?.enabled).toBe(0);
177 });
178 
179 it("does not let concurrent create-like requests replace an existing secret", async () => {
180 const context = await createOwnedProjectContext({
181 user: {
182 email: "webhook-private-concurrent@example.com",
183 slug: "webhook-private-concurrent",
184 },
185 project: {
186 projectSlug: "concurrent-secret-project",
187 repoUrl: "https://github.com/example/concurrent-secret-project",
188 },
189 });
190 
191 const [firstAttempt, secondAttempt] = await Promise.all([
192 putWebhook(context.sessionId, context.project.id, "github", {
193 enabled: true,
194 secret: "concurrent-secret-a",
195 }),
196 putWebhook(context.sessionId, context.project.id, "github", {
197 enabled: true,
198 secret: "concurrent-secret-b",
199 }),
200 ]);
201 
202 const successResponses = [firstAttempt, secondAttempt].filter((response) => response.status < 300);
203 const failureResponses = [firstAttempt, secondAttempt].filter((response) => response.status >= 300);
204 
205 expect(successResponses).toHaveLength(1);
206 expect(failureResponses).toHaveLength(1);
207 expect([400, 409]).toContain(failureResponses[0]!.status);
208 
209 const rows = await readProjectDoRows(context.project.id);
210 expect(rows.webhooks).toHaveLength(1);
211 
212 const listed = await getWebhooks(context.sessionId, context.project.id);
213 expect(listed.status).toBe(200);
214 expect(listed.body?.webhooks).toHaveLength(1);
215 expect(listed.text).not.toContain("concurrent-secret-a");
216 expect(listed.text).not.toContain("concurrent-secret-b");
217 
218 const detail = await getProjectDetail(context.sessionId, context.project.id);
219 expect(detail.status).toBe(200);
220 });
221 });
222});