1
13import { afterAll, beforeAll, describe, expect, it } from "vitest";
14import { Queue } from "bullmq";
15import { getRedisUrl } from "./helpers/containers";
16import { createTestApp, type TestAppHandle } from "./helpers/api";
17import { authHeaders, createUser } from "./helpers/seed";
18import type { PrismaClient } from "@prisma/client";
19
20let app: TestAppHandle;
21
22const json = async (res: Response): Promise<Record<string, unknown>> =>
23 (await res.json().catch(() => ({}))) as Record<string, unknown>;
24
25const reasonOf = (body: Record<string, unknown>): string | undefined =>
26 ((body.error as { details?: Array<{ reason?: string }> })?.details?.[0]?.reason);
27
28async function makeOrg(tok: string): Promise<string> {
29 const res = await app.request("POST", "/api/v1/orgs", {
30 headers: authHeaders(tok),
31 body: { name: `Reindex Org ${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
32 });
33 const body = (await json(res)) as { id?: string; organization?: { id?: string } };
34 const orgId = body.id ?? body.organization?.id;
35 if (!orgId) throw new Error(`makeOrg failed: ${res.status} ${await res.text()}`);
36 return orgId;
37}
38
39async function addOrgMember(prisma: PrismaClient, orgId: string, userId: string, role: "ADMIN" | "MEMBER" | "GUEST") {
40 await prisma.organizationMember.upsert({
41 where: { organizationId_userId: { organizationId: orgId, userId } },
42 update: { role },
43 create: { organizationId: orgId, userId, role }
44 });
45}
46
47
52async function drainSearchIndexQueue(): Promise<Array<{ id: string; data: Record<string, unknown> }>> {
53 const url = process.env.REDIS_URL ?? (await getRedisUrl());
54 const q = new Queue("search-index", { connection: { url } });
55 try {
56 const jobs = await q.getJobs(["wait"], 0, -1);
57 return jobs.map((j) => ({ id: j.id ?? "", data: (j.data ?? {}) as Record<string, unknown> }));
58 } finally {
59 await q.close();
60 }
61}
62
63describe("T-156: admin search reindex trigger", () => {
64 beforeAll(async () => {
65 app = await createTestApp();
66 }, 180_000);
67 afterAll(async () => {
68 await app?.close();
69 });
70
71 it("org-admin enqueues an org-scoped reindex job (+audit); others get 403", async () => {
72 const owner = await createUser(app.request, { name: "RX Owner" });
73 const admin = await createUser(app.request, { name: "RX Admin" });
74 const member = await createUser(app.request, { name: "RX Member" });
75 const outsider = await createUser(app.request, { name: "RX Outsider" });
76 const orgId = await makeOrg(owner.accessToken!);
77 await addOrgMember(app.prisma, orgId, admin.userId, "ADMIN");
78 await addOrgMember(app.prisma, orgId, member.userId, "MEMBER");
79
80
81 for (const [u, tok] of [
82 [member, member.accessToken!],
83 [outsider, outsider.accessToken!]
84 ] as const) {
85 const denied = await app.request("POST", `/api/v1/orgs/${orgId}/admin/search/reindex`, {
86 headers: authHeaders(tok)
87 });
88 expect(denied.status).toBe(403);
89 expect(reasonOf(await json(denied))).toBe("requires_org_admin");
90 }
91
92
93
94 for (const tok of [admin.accessToken!, owner.accessToken!]) {
95 const res = await app.request("POST", `/api/v1/orgs/${orgId}/admin/search/reindex`, {
96 headers: authHeaders(tok)
97 });
98 expect(res.status).toBe(201);
99 const body = (await json(res)) as { jobId?: string; status?: string };
100 expect(body.status).toBe("processing");
101 expect(body.jobId).toBeTruthy();
102 }
103 const jobs = await drainSearchIndexQueue();
104 expect(jobs.length).toBe(2);
105 for (const j of jobs) {
106 expect(j.data).toMatchObject({ type: "reindex", orgId });
107 expect(j.data).not.toHaveProperty("workspaceId");
108 }
109
110
111
112
113 const audits = await app.prisma.auditLog.findMany({
114 where: { action: "search.reindex_requested", resourceId: orgId },
115 orderBy: { createdAt: "desc" }
116 });
117 expect(audits.length).toBe(2);
118 expect(audits[0]?.actorId).toBeDefined();
119 expect(audits[0]?.resourceType).toBe("organization");
120 });
121
122 it("workspaceId of ANOTHER org → 404 (no-leak); own org → 201 + scoped job", async () => {
123 const owner = await createUser(app.request, { name: "RX2 Owner" });
124 const other = await createUser(app.request, { name: "RX2 Other" });
125 const org1 = await makeOrg(owner.accessToken!);
126 const org2 = await makeOrg(other.accessToken!);
127
128
129 const wsRes = await app.request("POST", "/api/v1/workspaces", {
130 headers: authHeaders(other.accessToken!),
131 body: { name: "RX2 WS" }
132 });
133 expect(wsRes.status).toBe(201);
134 const ws = (await json(wsRes)) as { id: string };
135
136 await app.prisma.workspace.update({ where: { id: ws.id }, data: { organizationId: org2 } });
137
138 const denied = await app.request("POST", `/api/v1/orgs/${org1}/admin/search/reindex?workspaceId=${ws.id}`, {
139 headers: authHeaders(owner.accessToken!)
140 });
141 expect(denied.status).toBe(404);
142
143
144 const ownWsRes = await app.request("POST", "/api/v1/workspaces", {
145 headers: authHeaders(owner.accessToken!),
146 body: { name: "RX2 Own WS" }
147 });
148 const ownWs = (await json(ownWsRes)) as { id: string };
149 await app.prisma.workspace.update({ where: { id: ownWs.id }, data: { organizationId: org1 } });
150 const ok = await app.request("POST", `/api/v1/orgs/${org1}/admin/search/reindex?workspaceId=${ownWs.id}`, {
151 headers: authHeaders(owner.accessToken!)
152 });
153 expect(ok.status).toBe(201);
154 const body = (await json(ok)) as { workspaceId?: string };
155 expect(body.workspaceId).toBe(ownWs.id);
156 const jobs = await drainSearchIndexQueue();
157 const scoped = jobs.find((j) => (j.data.workspaceId as string) === ownWs.id);
158 expect(scoped).toBeTruthy();
159 expect(scoped?.data).toMatchObject({ type: "reindex", orgId: org1, workspaceId: ownWs.id });
160 });
161});
162