Skip to content
File

Blob: tests/worker/routes/visibility-and-atomicity.test.ts

typescript220 lines
1import { env } from "cloudflare:workers";
2import { describe, expect, it, vi } from "vitest";
3 
4import { type ProjectDetail, type ProjectResponse, type TriggerRunAcceptedResponse } from "@/contracts";
5 
6import { authHeaders, fetchJson, mintCookieAuth, seedUser } from "../../helpers/runtime";
7import { registerWorkerRuntimeHooks } from "../../helpers/worker-hooks";
8 
9describe("worker routes", () => {
10 registerWorkerRuntimeHooks();
11 
12 describe("visibility and atomicity", () => {
13 it("does not persist webhook-relevant project updates when ProjectDO config mutation fails", async () => {
14 const user = await seedUser({
15 email: "routes-webhook-version-touch@example.com",
16 slug: "routes-webhook-version-touch",
17 });
18 
19 const { sessionId } = await mintCookieAuth(user.id);
20 
21 const createdProject = await fetchJson<ProjectResponse>("/api/private/projects", {
22 method: "POST",
23 headers: authHeaders(sessionId, {
24 "content-type": "application/json; charset=utf-8",
25 }),
26 body: JSON.stringify({
27 projectSlug: "version-touch-project",
28 name: "Version Touch Project",
29 repoUrl: "https://github.com/example/version-touch-project",
30 defaultBranch: "main",
31 configPath: ".anvil.yml",
32 dispatchMode: "queue",
33 }),
34 });
35 expect(createdProject.status).toBe(201);
36 expect(createdProject.body).not.toBeNull();
37 
38 const project = createdProject.body!.project;
39 const createdWebhook = await fetchJson(`/api/private/projects/${project.id}/webhooks/github`, {
40 method: "PUT",
41 headers: authHeaders(sessionId, {
42 "content-type": "application/json; charset=utf-8",
43 }),
44 body: JSON.stringify({
45 enabled: true,
46 secret: "routes-version-touch-secret",
47 }),
48 });
49 expect(createdWebhook.status).toBe(201);
50 
51 const originalGetByName = env.PROJECT_DO.getByName.bind(env.PROJECT_DO);
52 const getByNameSpy = vi.spyOn(env.PROJECT_DO, "getByName").mockImplementation((name) => {
53 if (name !== project.id) {
54 return originalGetByName(name);
55 }
56 
57 return {
58 updateProjectConfig: async () => {
59 throw new Error("update_project_config_failed");
60 },
61 } as unknown as ReturnType<typeof env.PROJECT_DO.getByName>;
62 });
63 
64 try {
65 const failedUpdate = await fetchJson(`/api/private/projects/${project.id}`, {
66 method: "PATCH",
67 headers: authHeaders(sessionId, {
68 "content-type": "application/json; charset=utf-8",
69 }),
70 body: JSON.stringify({
71 defaultBranch: "develop",
72 }),
73 });
74 
75 expect(failedUpdate.status).toBe(500);
76 expect(failedUpdate.body).toMatchObject({
77 error: {
78 code: "internal_error",
79 },
80 });
81 } finally {
82 getByNameSpy.mockRestore();
83 }
84 
85 const projectDetail = await fetchJson<ProjectDetail>(`/api/private/projects/${project.id}`, {
86 headers: authHeaders(sessionId),
87 });
88 expect(projectDetail.status).toBe(200);
89 expect(projectDetail.body?.project.defaultBranch).toBe("main");
90 });
91 
92 it("masks project and run ownership checks as not found for other users", async () => {
93 const owner = await seedUser({
94 email: "routes-owner@example.com",
95 slug: "routes-owner",
96 });
97 const otherUser = await seedUser({
98 email: "routes-other@example.com",
99 slug: "routes-other",
100 });
101 
102 const { sessionId: ownerSessionId } = await mintCookieAuth(owner.id);
103 const { sessionId: otherSessionId } = await mintCookieAuth(otherUser.id);
104 
105 const createdProject = await fetchJson<ProjectResponse>("/api/private/projects", {
106 method: "POST",
107 headers: authHeaders(ownerSessionId, {
108 "content-type": "application/json; charset=utf-8",
109 }),
110 body: JSON.stringify({
111 projectSlug: "owner-only-project",
112 name: "Owner Only Project",
113 repoUrl: "https://github.com/example/owner-only-project",
114 defaultBranch: "main",
115 configPath: ".anvil.yml",
116 dispatchMode: "queue",
117 }),
118 });
119 expect(createdProject.status).toBe(201);
120 expect(createdProject.body).not.toBeNull();
121 
122 const projectId = createdProject.body!.project.id;
123 
124 const triggeredRun = await fetchJson<TriggerRunAcceptedResponse>(`/api/private/projects/${projectId}/runs`, {
125 method: "POST",
126 headers: authHeaders(ownerSessionId, {
127 "content-type": "application/json; charset=utf-8",
128 }),
129 body: JSON.stringify({}),
130 });
131 expect(triggeredRun.status).toBe(202);
132 expect(triggeredRun.body).not.toBeNull();
133 
134 const runId = triggeredRun.body!.runId;
135 
136 const projectDetail = await fetchJson(`/api/private/projects/${projectId}`, {
137 headers: authHeaders(otherSessionId),
138 });
139 expect(projectDetail.status).toBe(404);
140 expect(projectDetail.body).toMatchObject({
141 error: {
142 code: "project_not_found",
143 },
144 });
145 
146 const projectRuns = await fetchJson(`/api/private/projects/${projectId}/runs`, {
147 headers: authHeaders(otherSessionId),
148 });
149 expect(projectRuns.status).toBe(404);
150 expect(projectRuns.body).toMatchObject({
151 error: {
152 code: "project_not_found",
153 },
154 });
155 
156 const updatedProject = await fetchJson(`/api/private/projects/${projectId}`, {
157 method: "PATCH",
158 headers: authHeaders(otherSessionId, {
159 "content-type": "application/json; charset=utf-8",
160 }),
161 body: JSON.stringify({
162 name: "Renamed by intruder",
163 }),
164 });
165 expect(updatedProject.status).toBe(404);
166 expect(updatedProject.body).toMatchObject({
167 error: {
168 code: "project_not_found",
169 },
170 });
171 
172 const triggeredByOtherUser = await fetchJson(`/api/private/projects/${projectId}/runs`, {
173 method: "POST",
174 headers: authHeaders(otherSessionId, {
175 "content-type": "application/json; charset=utf-8",
176 }),
177 body: JSON.stringify({}),
178 });
179 expect(triggeredByOtherUser.status).toBe(404);
180 expect(triggeredByOtherUser.body).toMatchObject({
181 error: {
182 code: "project_not_found",
183 },
184 });
185 
186 const runDetail = await fetchJson(`/api/private/runs/${runId}`, {
187 headers: authHeaders(otherSessionId),
188 });
189 expect(runDetail.status).toBe(404);
190 expect(runDetail.body).toMatchObject({
191 error: {
192 code: "run_not_found",
193 },
194 });
195 
196 const canceledRun = await fetchJson(`/api/private/runs/${runId}/cancel`, {
197 method: "POST",
198 headers: authHeaders(otherSessionId),
199 });
200 expect(canceledRun.status).toBe(404);
201 expect(canceledRun.body).toMatchObject({
202 error: {
203 code: "run_not_found",
204 },
205 });
206 
207 const logTicket = await fetchJson(`/api/private/runs/${runId}/log-ticket`, {
208 method: "POST",
209 headers: authHeaders(otherSessionId),
210 });
211 expect(logTicket.status).toBe(404);
212 expect(logTicket.body).toMatchObject({
213 error: {
214 code: "run_not_found",
215 },
216 });
217 });
218 });
219});