File
Blob: tests/worker/routes/page-mentions.workers.test.ts
| 1 | import { beforeEach, describe, expect, it } from "vitest"; |
| 2 | |
| 3 | import { ResolvePageMentionsResponse } from "@/shared/types"; |
| 4 | import { resetD1Tables } from "@tests/worker/helpers/db"; |
| 5 | import { PROD_ORIGIN, apiRequest } from "@tests/worker/helpers/request"; |
| 6 | import { ApiErrorResponse } from "@tests/worker/helpers/schemas"; |
| 7 | import { seedMembership, seedPage, seedPageShare, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds"; |
| 8 | |
| 9 | async 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 | |
| 23 | describe("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 | }); |