Skip to content
File

Blob: tests/worker/routes/page-mentions.workers.test.ts

typescript203 lines
1import { beforeEach, describe, expect, it } from "vitest";
2 
3import { ResolvePageMentionsResponse } from "@/shared/types";
4import { resetD1Tables } from "@tests/worker/helpers/db";
5import { PROD_ORIGIN, apiRequest } from "@tests/worker/helpers/request";
6import { ApiErrorResponse } from "@tests/worker/helpers/schemas";
7import { seedMembership, seedPage, seedPageShare, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds";
8 
9async function postResolve(
10 workspaceId: string,
11 body: { page_ids: string[] },
12 opts: { userId?: string; shareToken?: string; origin?: string } = {},
13) {
14 return apiRequest(`/api/v1/workspaces/${workspaceId}/page-mentions/resolve`, {
15 method: "POST",
16 body,
17 userId: opts.userId,
18 shareToken: opts.shareToken,
19 origin: opts.origin,
20 });
21}
22 
23describe("POST /workspaces/:wid/page-mentions/resolve", () => {
24 beforeEach(async () => {
25 await resetD1Tables();
26 });
27 
28 it("resolves accessible pages for a full member", async () => {
29 const owner = await seedUser();
30 const member = await seedUser();
31 const ws = await seedWorkspace({ owner_id: owner.id });
32 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
33 const alpha = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Alpha", icon: "A" });
34 const beta = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Beta" });
35 
36 const res = await postResolve(ws.id, { page_ids: [alpha.id, beta.id] }, { userId: member.id });
37 expect(res.status).toBe(200);
38 const body = ResolvePageMentionsResponse.parse(await res.json());
39 expect(body.mentions).toEqual([
40 { page_id: alpha.id, accessible: true, title: "Alpha", icon: "A" },
41 { page_id: beta.id, accessible: true, title: "Beta", icon: null },
42 ]);
43 });
44 
45 it("resolves shared-link mentions when the principal is a link token", async () => {
46 const owner = await seedUser();
47 const ws = await seedWorkspace({ owner_id: owner.id });
48 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Alpha" });
49 await seedPageShare({
50 page_id: page.id,
51 created_by: owner.id,
52 grantee_type: "link",
53 grantee_id: null,
54 link_token: "tok-link",
55 permission: "view",
56 });
57 
58 const res = await postResolve(ws.id, { page_ids: [page.id] }, { shareToken: "tok-link" });
59 expect(res.status).toBe(200);
60 const body = ResolvePageMentionsResponse.parse(await res.json());
61 expect(body.mentions[0].accessible).toBe(true);
62 });
63 
64 it("keeps full-member access when a member request also carries ?share=", async () => {
65 const owner = await seedUser();
66 const member = await seedUser();
67 const ws = await seedWorkspace({ owner_id: owner.id });
68 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
69 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Alpha" });
70 await seedPageShare({
71 page_id: page.id,
72 created_by: owner.id,
73 grantee_type: "link",
74 grantee_id: null,
75 link_token: "tok-combo",
76 permission: "view",
77 });
78 
79 const res = await postResolve(ws.id, { page_ids: [page.id] }, { userId: member.id, shareToken: "tok-combo" });
80 expect(res.status).toBe(200);
81 const body = ResolvePageMentionsResponse.parse(await res.json());
82 expect(body.mentions[0].accessible).toBe(true);
83 });
84 
85 it("resolves canonical user-grantee shared access without a share token", async () => {
86 const owner = await seedUser();
87 const outsider = await seedUser();
88 const ws = await seedWorkspace({ owner_id: owner.id });
89 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Alpha" });
90 await seedPageShare({
91 page_id: page.id,
92 created_by: owner.id,
93 grantee_type: "user",
94 grantee_id: outsider.id,
95 permission: "view",
96 });
97 
98 const res = await postResolve(ws.id, { page_ids: [page.id] }, { userId: outsider.id });
99 expect(res.status).toBe(200);
100 const body = ResolvePageMentionsResponse.parse(await res.json());
101 expect(body.mentions[0].accessible).toBe(true);
102 });
103 
104 it("collapses inaccessible ids to restricted without leaking title or icon", async () => {
105 const owner = await seedUser();
106 const ws = await seedWorkspace({ owner_id: owner.id });
107 const visible = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Alpha", icon: "A" });
108 const blocked = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Secret" });
109 await seedPageShare({
110 page_id: visible.id,
111 created_by: owner.id,
112 grantee_type: "link",
113 grantee_id: null,
114 link_token: "tok-restrict",
115 permission: "view",
116 });
117 
118 const res = await postResolve(ws.id, { page_ids: [visible.id, blocked.id] }, { shareToken: "tok-restrict" });
119 expect(res.status).toBe(200);
120 const body = ResolvePageMentionsResponse.parse(await res.json());
121 expect(body.mentions).toEqual([
122 { page_id: visible.id, accessible: true, title: "Alpha", icon: "A" },
123 { page_id: blocked.id, accessible: false, title: null, icon: null },
124 ]);
125 });
126 
127 it("collapses archived accessible pages to restricted mention entries", async () => {
128 const owner = await seedUser();
129 const member = await seedUser();
130 const ws = await seedWorkspace({ owner_id: owner.id });
131 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
132 const archived = await seedPage({
133 workspace_id: ws.id,
134 created_by: owner.id,
135 title: "Tombstone",
136 archived_at: "2026-04-01T00:00:00.000Z",
137 });
138 
139 const res = await postResolve(ws.id, { page_ids: [archived.id] }, { userId: member.id });
140 expect(res.status).toBe(200);
141 const body = ResolvePageMentionsResponse.parse(await res.json());
142 expect(body.mentions).toEqual([{ page_id: archived.id, accessible: false, title: null, icon: null }]);
143 });
144 
145 it("returns 404 when the workspace does not exist", async () => {
146 const caller = await seedUser();
147 
148 const res = await postResolve("ws-unknown", { page_ids: ["p-1"] }, { userId: caller.id });
149 expect(res.status).toBe(404);
150 expect(ApiErrorResponse.parse(await res.json()).error).toBe("not_found");
151 });
152 
153 it("returns 401 when no principal can be resolved (anonymous, no share token)", async () => {
154 const owner = await seedUser();
155 const ws = await seedWorkspace({ owner_id: owner.id });
156 
157 const res = await postResolve(ws.id, { page_ids: ["p-1"] }, { origin: PROD_ORIGIN });
158 expect(res.status).toBe(401);
159 });
160 
161 it("rejects batches larger than the cap", async () => {
162 const owner = await seedUser();
163 const member = await seedUser();
164 const ws = await seedWorkspace({ owner_id: owner.id });
165 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
166 
167 const ids = Array.from({ length: 101 }, (_, i) => `p-${i.toString().padStart(3, "0")}`);
168 const res = await postResolve(ws.id, { page_ids: ids }, { userId: member.id });
169 expect(res.status).toBe(400);
170 });
171 
172 it("dedupes duplicate ids and only resolves unique ones", async () => {
173 const owner = await seedUser();
174 const member = await seedUser();
175 const ws = await seedWorkspace({ owner_id: owner.id });
176 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
177 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Alpha" });
178 
179 const res = await postResolve(ws.id, { page_ids: [page.id, page.id, page.id] }, { userId: member.id });
180 expect(res.status).toBe(200);
181 const body = ResolvePageMentionsResponse.parse(await res.json());
182 expect(body.mentions).toHaveLength(1);
183 expect(body.mentions[0].page_id).toBe(page.id);
184 });
185 
186 it("collapses unknown ids to restricted instead of rejecting the whole batch", async () => {
187 const owner = await seedUser();
188 const member = await seedUser();
189 const ws = await seedWorkspace({ owner_id: owner.id });
190 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
191 const known = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Alpha" });
192 const unknownId = "page-unknown";
193 
194 const res = await postResolve(ws.id, { page_ids: [known.id, unknownId] }, { userId: member.id });
195 expect(res.status).toBe(200);
196 const body = ResolvePageMentionsResponse.parse(await res.json());
197 expect(body.mentions).toEqual([
198 { page_id: known.id, accessible: true, title: "Alpha", icon: null },
199 { page_id: unknownId, accessible: false, title: null, icon: null },
200 ]);
201 });
202});