File
Blob: tests/worker/routes/search-membership.workers.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { beforeEach, describe, expect, it } from "vitest"; |
| 3 | import { z } from "zod"; |
| 4 | |
| 5 | import { SearchResult } from "@/shared/types"; |
| 6 | import { resetD1Tables } from "@tests/worker/helpers/db"; |
| 7 | import { apiRequest } from "@tests/worker/helpers/request"; |
| 8 | import { seedMembership, seedPage, seedPageShare, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds"; |
| 9 | |
| 10 | const SearchResponse = z.object({ results: z.array(SearchResult) }); |
| 11 | |
| 12 | async function indexPage(workspaceId: string, pageId: string, title: string, bodyText: string): Promise<void> { |
| 13 | const stub = env.WorkspaceIndexer.getByName(workspaceId); |
| 14 | const result = await stub.indexPage(pageId, title, bodyText); |
| 15 | if (result.kind !== "indexed") { |
| 16 | throw new Error(`indexPage failed for ${pageId}: ${JSON.stringify(result)}`); |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | async function clearIndex(workspaceId: string): Promise<void> { |
| 21 | const stub = env.WorkspaceIndexer.getByName(workspaceId); |
| 22 | await stub.clear(); |
| 23 | } |
| 24 | |
| 25 | describe("GET /workspaces/:wid/search - membership gating", () => { |
| 26 | beforeEach(async () => { |
| 27 | await resetD1Tables(); |
| 28 | }); |
| 29 | |
| 30 | it("returns empty results for non-members with no accessible hits", async () => { |
| 31 | const owner = await seedUser(); |
| 32 | const outsider = await seedUser(); |
| 33 | const ws = await seedWorkspace({ owner_id: owner.id }); |
| 34 | const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Alpha" }); |
| 35 | await clearIndex(ws.id); |
| 36 | await indexPage(ws.id, page.id, "Alpha", "something about testing"); |
| 37 | |
| 38 | const res = await apiRequest(`/api/v1/workspaces/${ws.id}/search`, { |
| 39 | search: { q: "testing" }, |
| 40 | userId: outsider.id, |
| 41 | }); |
| 42 | |
| 43 | expect(res.status).toBe(200); |
| 44 | const body = SearchResponse.parse(await res.json()); |
| 45 | expect(body.results).toEqual([]); |
| 46 | }); |
| 47 | |
| 48 | it("returns matching pages for full workspace members", async () => { |
| 49 | const owner = await seedUser(); |
| 50 | const member = await seedUser(); |
| 51 | const ws = await seedWorkspace({ owner_id: owner.id }); |
| 52 | await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" }); |
| 53 | const page = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Alpha" }); |
| 54 | await clearIndex(ws.id); |
| 55 | await indexPage(ws.id, page.id, "Alpha", "unique-search-keyword-for-members"); |
| 56 | |
| 57 | const res = await apiRequest(`/api/v1/workspaces/${ws.id}/search`, { |
| 58 | search: { q: "unique-search-keyword-for-members" }, |
| 59 | userId: member.id, |
| 60 | }); |
| 61 | |
| 62 | expect(res.status).toBe(200); |
| 63 | const body = SearchResponse.parse(await res.json()); |
| 64 | expect(body.results).toHaveLength(1); |
| 65 | expect(body.results[0].page_id).toBe(page.id); |
| 66 | expect(body.results[0].title).toBe("Alpha"); |
| 67 | }); |
| 68 | |
| 69 | it("excludes archived pages from member results", async () => { |
| 70 | const owner = await seedUser(); |
| 71 | const member = await seedUser(); |
| 72 | const ws = await seedWorkspace({ owner_id: owner.id }); |
| 73 | await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" }); |
| 74 | const livePage = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Live" }); |
| 75 | const archivedPage = await seedPage({ |
| 76 | workspace_id: ws.id, |
| 77 | created_by: owner.id, |
| 78 | title: "Old", |
| 79 | archived_at: "2026-04-01T00:00:00.000Z", |
| 80 | }); |
| 81 | await clearIndex(ws.id); |
| 82 | await indexPage(ws.id, livePage.id, "Live", "archive-gating-live-keyword"); |
| 83 | await indexPage(ws.id, archivedPage.id, "Old", "archive-gating-live-keyword"); |
| 84 | |
| 85 | const res = await apiRequest(`/api/v1/workspaces/${ws.id}/search`, { |
| 86 | search: { q: "archive-gating-live-keyword" }, |
| 87 | userId: member.id, |
| 88 | }); |
| 89 | |
| 90 | expect(res.status).toBe(200); |
| 91 | const body = SearchResponse.parse(await res.json()); |
| 92 | expect(body.results.map((r) => r.page_id)).toEqual([livePage.id]); |
| 93 | }); |
| 94 | |
| 95 | it("post-filters guest results by per-page share access", async () => { |
| 96 | const owner = await seedUser(); |
| 97 | const guest = await seedUser(); |
| 98 | const ws = await seedWorkspace({ owner_id: owner.id }); |
| 99 | await seedMembership({ user_id: guest.id, workspace_id: ws.id, role: "guest" }); |
| 100 | const sharedWithGuest = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Granted" }); |
| 101 | const hiddenFromGuest = await seedPage({ workspace_id: ws.id, created_by: owner.id, title: "Secret" }); |
| 102 | await seedPageShare({ |
| 103 | page_id: sharedWithGuest.id, |
| 104 | created_by: owner.id, |
| 105 | grantee_type: "user", |
| 106 | grantee_id: guest.id, |
| 107 | permission: "view", |
| 108 | }); |
| 109 | await clearIndex(ws.id); |
| 110 | await indexPage(ws.id, sharedWithGuest.id, "Granted", "guest-post-filter-keyword"); |
| 111 | await indexPage(ws.id, hiddenFromGuest.id, "Secret", "guest-post-filter-keyword"); |
| 112 | |
| 113 | const res = await apiRequest(`/api/v1/workspaces/${ws.id}/search`, { |
| 114 | search: { q: "guest-post-filter-keyword" }, |
| 115 | userId: guest.id, |
| 116 | }); |
| 117 | |
| 118 | expect(res.status).toBe(200); |
| 119 | const body = SearchResponse.parse(await res.json()); |
| 120 | expect(body.results.map((r) => r.page_id)).toEqual([sharedWithGuest.id]); |
| 121 | }); |
| 122 | }); |