Skip to content
File

Blob: tests/worker/routes/ai-entitlement.workers.test.ts

typescript275 lines
1import { beforeEach, describe, expect, it } from "vitest";
2 
3import { resetD1Tables } from "@tests/worker/helpers/db";
4import { PROD_ORIGIN, apiRequest } from "@tests/worker/helpers/request";
5import { seedMembership, seedPage, seedPageShare, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds";
6import { buildYjsDocBytes, seedDocSyncSnapshot } from "@tests/worker/helpers/do";
7 
8const REWRITE_PAYLOAD = {
9 action: "proofread",
10 selectedText: "hi",
11 parentBlock: "hi",
12 beforeBlock: "",
13 afterBlock: "",
14 pageTitle: "",
15};
16 
17describe("AI route entitlement gating", () => {
18 beforeEach(async () => {
19 await resetD1Tables();
20 });
21 
22 it("returns 401 when no principal can be resolved", async () => {
23 const owner = await seedUser();
24 const ws = await seedWorkspace({ owner_id: owner.id });
25 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
26 
27 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/rewrite`, {
28 method: "POST",
29 body: REWRITE_PAYLOAD,
30 origin: PROD_ORIGIN,
31 });
32 expect(res.status).toBe(401);
33 const body = (await res.json()) as { error: string };
34 expect(body.error).toBe("unauthorized");
35 });
36 
37 it("returns 403 ai_not_entitled for a non-member canonical viewer with share access attempting rewrite", async () => {
38 const owner = await seedUser();
39 const outsider = await seedUser();
40 const ws = await seedWorkspace({ owner_id: owner.id });
41 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
42 await seedPageShare({
43 page_id: page.id,
44 created_by: owner.id,
45 grantee_type: "user",
46 grantee_id: outsider.id,
47 permission: "view",
48 });
49 
50 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/rewrite`, {
51 method: "POST",
52 body: REWRITE_PAYLOAD,
53 userId: outsider.id,
54 });
55 expect(res.status).toBe(403);
56 const body = (await res.json()) as { error: string };
57 expect(body.error).toBe("ai_not_entitled");
58 });
59 
60 it("denies every AI action for a guest on canonical surface even with edit share access", async () => {
61 const owner = await seedUser();
62 const guest = await seedUser();
63 const ws = await seedWorkspace({ owner_id: owner.id });
64 await seedMembership({ user_id: guest.id, workspace_id: ws.id, role: "guest" });
65 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
66 await seedPageShare({
67 page_id: page.id,
68 created_by: owner.id,
69 grantee_type: "user",
70 grantee_id: guest.id,
71 permission: "edit",
72 });
73 await seedDocSyncSnapshot(page.id, buildYjsDocBytes("Page", "Hello body"));
74 
75 const cases: Array<{ path: string; body?: unknown }> = [
76 { path: `/api/v1/workspaces/${ws.id}/pages/${page.id}/rewrite`, body: REWRITE_PAYLOAD },
77 {
78 path: `/api/v1/workspaces/${ws.id}/pages/${page.id}/generate`,
79 body: { intent: "continue", beforeBlock: "", afterBlock: "", pageTitle: "" },
80 },
81 { path: `/api/v1/workspaces/${ws.id}/pages/${page.id}/summarize` },
82 { path: `/api/v1/workspaces/${ws.id}/pages/${page.id}/ask`, body: { question: "What is this?" } },
83 ];
84 
85 for (const { path, body } of cases) {
86 const res = await apiRequest(path, {
87 method: "POST",
88 body: body as never,
89 userId: guest.id,
90 });
91 expect(res.status, `guest should be denied on ${path}`).toBe(403);
92 const payload = (await res.json()) as { error: string };
93 expect(payload.error).toBe("ai_not_entitled");
94 }
95 });
96 
97 it("full members keep edit entitlement even when they also carry a redundant view-only user share", async () => {
98 const owner = await seedUser();
99 const member = await seedUser();
100 const ws = await seedWorkspace({ owner_id: owner.id });
101 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
102 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
103 await seedPageShare({
104 page_id: page.id,
105 created_by: owner.id,
106 grantee_type: "user",
107 grantee_id: member.id,
108 permission: "view",
109 });
110 
111 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/rewrite`, {
112 method: "POST",
113 body: REWRITE_PAYLOAD,
114 userId: member.id,
115 });
116 expect(res.status).toBe(200);
117 expect(res.headers.get("content-type")).toContain("text/event-stream");
118 });
119 
120 it("allows a canonical member to summarize a page with body text", async () => {
121 const owner = await seedUser();
122 const member = await seedUser();
123 const ws = await seedWorkspace({ owner_id: owner.id });
124 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
125 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
126 await seedDocSyncSnapshot(page.id, buildYjsDocBytes("Page", "This is the body."));
127 
128 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/summarize`, {
129 method: "POST",
130 userId: member.id,
131 });
132 expect(res.status).toBe(200);
133 const body = (await res.json()) as { summary: string };
134 expect(typeof body.summary).toBe("string");
135 expect(body.summary.length).toBeGreaterThan(0);
136 });
137 
138 it("allows a canonical editor (full member) to rewrite and streams SSE", async () => {
139 const owner = await seedUser();
140 const member = await seedUser();
141 const ws = await seedWorkspace({ owner_id: owner.id });
142 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
143 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
144 
145 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/rewrite`, {
146 method: "POST",
147 body: REWRITE_PAYLOAD,
148 userId: member.id,
149 });
150 expect(res.status).toBe(200);
151 expect(res.headers.get("content-type")).toContain("text/event-stream");
152 await res.body?.cancel();
153 });
154 
155 it("returns 404 not_found when the page does not exist", async () => {
156 const owner = await seedUser();
157 const ws = await seedWorkspace({ owner_id: owner.id });
158 
159 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/page-does-not-exist/rewrite`, {
160 method: "POST",
161 body: REWRITE_PAYLOAD,
162 userId: owner.id,
163 });
164 expect(res.status).toBe(404);
165 const body = (await res.json()) as { error: string };
166 expect(body.error).toBe("not_found");
167 });
168 
169 it("returns 404 not_found when access level is none (no existence leak)", async () => {
170 const owner = await seedUser();
171 const outsider = await seedUser();
172 const ws = await seedWorkspace({ owner_id: owner.id });
173 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
174 
175 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/rewrite`, {
176 method: "POST",
177 body: REWRITE_PAYLOAD,
178 userId: outsider.id,
179 });
180 expect(res.status).toBe(404);
181 const body = (await res.json()) as { error: string };
182 expect(body.error).toBe("not_found");
183 });
184 
185 it("returns 404 not_found for an inaccessible canvas page (no kind leak)", async () => {
186 const owner = await seedUser();
187 const outsider = await seedUser();
188 const ws = await seedWorkspace({ owner_id: owner.id });
189 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, kind: "canvas" });
190 
191 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/rewrite`, {
192 method: "POST",
193 body: REWRITE_PAYLOAD,
194 userId: outsider.id,
195 });
196 expect(res.status).toBe(404);
197 const body = (await res.json()) as { error: string };
198 expect(body.error).toBe("not_found");
199 });
200 
201 it("returns 404 page_empty for an accessible canvas page (kind check after access)", async () => {
202 const owner = await seedUser();
203 const member = await seedUser();
204 const ws = await seedWorkspace({ owner_id: owner.id });
205 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
206 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, kind: "canvas" });
207 
208 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/rewrite`, {
209 method: "POST",
210 body: REWRITE_PAYLOAD,
211 userId: member.id,
212 });
213 expect(res.status).toBe(404);
214 const body = (await res.json()) as { error: string };
215 expect(body.error).toBe("page_empty");
216 });
217});
218 
219describe("AI empty-page gating", () => {
220 beforeEach(async () => {
221 await resetD1Tables();
222 });
223 
224 it("returns 404 page_empty on /ask when the page body is empty (no snapshot)", async () => {
225 const owner = await seedUser();
226 const member = await seedUser();
227 const ws = await seedWorkspace({ owner_id: owner.id });
228 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
229 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
230 
231 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/ask`, {
232 method: "POST",
233 body: { question: "What is this?" },
234 userId: member.id,
235 });
236 expect(res.status).toBe(404);
237 const body = (await res.json()) as { error: string };
238 expect(body.error).toBe("page_empty");
239 });
240 
241 it("returns 404 page_empty on /summarize when the page body is empty (no snapshot)", async () => {
242 const owner = await seedUser();
243 const member = await seedUser();
244 const ws = await seedWorkspace({ owner_id: owner.id });
245 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
246 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
247 
248 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/summarize`, {
249 method: "POST",
250 userId: member.id,
251 });
252 expect(res.status).toBe(404);
253 const body = (await res.json()) as { error: string };
254 expect(body.error).toBe("page_empty");
255 });
256 
257 it("returns 404 page_empty on /ask when the DocSync snapshot exists but body is empty", async () => {
258 const owner = await seedUser();
259 const member = await seedUser();
260 const ws = await seedWorkspace({ owner_id: owner.id });
261 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
262 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
263 await seedDocSyncSnapshot(page.id, buildYjsDocBytes("Title only", ""));
264 
265 const res = await apiRequest(`/api/v1/workspaces/${ws.id}/pages/${page.id}/ask`, {
266 method: "POST",
267 body: { question: "hi" },
268 userId: member.id,
269 });
270 expect(res.status).toBe(404);
271 const body = (await res.json()) as { error: string };
272 expect(body.error).toBe("page_empty");
273 });
274});