Skip to content
File

Blob: tests/worker/routes/search-membership.workers.test.ts

typescript123 lines
1import { env } from "cloudflare:workers";
2import { beforeEach, describe, expect, it } from "vitest";
3import { z } from "zod";
4 
5import { SearchResult } from "@/shared/types";
6import { resetD1Tables } from "@tests/worker/helpers/db";
7import { apiRequest } from "@tests/worker/helpers/request";
8import { seedMembership, seedPage, seedPageShare, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds";
9 
10const SearchResponse = z.object({ results: z.array(SearchResult) });
11 
12async 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 
20async function clearIndex(workspaceId: string): Promise<void> {
21 const stub = env.WorkspaceIndexer.getByName(workspaceId);
22 await stub.clear();
23}
24 
25describe("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});