/** * T-156 — Admin search reindex trigger. * * - `POST /orgs/:orgId/admin/search/reindex` is org-admin gated (owner + * org-ADMIN pass; org MEMBER / non-member → 403 requires_org_admin) * - enqueues an org-scoped `reindex` job on the `search-index` queue * (asserted by draining the live Redis queue — the worker itself is * covered by the worker unit tests, the worker's `reindex` job with * `orgId` filters the workspace scan by `organizationId`) * - audit row `search.reindex_requested` is written * - `workspaceId` of ANOTHER org → 404 (no-leak) */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Queue } from "bullmq"; import { getRedisUrl } from "./helpers/containers"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { authHeaders, createUser } from "./helpers/seed"; import type { PrismaClient } from "@prisma/client"; let app: TestAppHandle; const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; const reasonOf = (body: Record): string | undefined => ((body.error as { details?: Array<{ reason?: string }> })?.details?.[0]?.reason); async function makeOrg(tok: string): Promise { const res = await app.request("POST", "/api/v1/orgs", { headers: authHeaders(tok), body: { name: `Reindex Org ${Date.now()}-${Math.random().toString(36).slice(2, 6)}` } }); const body = (await json(res)) as { id?: string; organization?: { id?: string } }; const orgId = body.id ?? body.organization?.id; if (!orgId) throw new Error(`makeOrg failed: ${res.status} ${await res.text()}`); return orgId; } async function addOrgMember(prisma: PrismaClient, orgId: string, userId: string, role: "ADMIN" | "MEMBER" | "GUEST") { await prisma.organizationMember.upsert({ where: { organizationId_userId: { organizationId: orgId, userId } }, update: { role }, create: { organizationId: orgId, userId, role } }); } /** * Read the pending `search-index` jobs (the trigger's output) via BullMQ's * own `Queue.getJobs()` — the SAME primitive the app uses to enqueue, so the * spec cannot drift from a hand-rolled Redis key reader. */ async function drainSearchIndexQueue(): Promise }>> { const url = process.env.REDIS_URL ?? (await getRedisUrl()); const q = new Queue("search-index", { connection: { url } }); try { const jobs = await q.getJobs(["wait"], 0, -1); return jobs.map((j) => ({ id: j.id ?? "", data: (j.data ?? {}) as Record })); } finally { await q.close(); } } describe("T-156: admin search reindex trigger", () => { beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); it("org-admin enqueues an org-scoped reindex job (+audit); others get 403", async () => { const owner = await createUser(app.request, { name: "RX Owner" }); const admin = await createUser(app.request, { name: "RX Admin" }); const member = await createUser(app.request, { name: "RX Member" }); const outsider = await createUser(app.request, { name: "RX Outsider" }); const orgId = await makeOrg(owner.accessToken!); await addOrgMember(app.prisma, orgId, admin.userId, "ADMIN"); await addOrgMember(app.prisma, orgId, member.userId, "MEMBER"); // org MEMBER / non-member → 403 (the org-admin gate) for (const [u, tok] of [ [member, member.accessToken!], [outsider, outsider.accessToken!] ] as const) { const denied = await app.request("POST", `/api/v1/orgs/${orgId}/admin/search/reindex`, { headers: authHeaders(tok) }); expect(denied.status).toBe(403); expect(reasonOf(await json(denied))).toBe("requires_org_admin"); } // org-ADMIN (and the owner) may trigger; the JOB carries the orgId so the // worker limits the scan to this org's workspaces. for (const tok of [admin.accessToken!, owner.accessToken!]) { const res = await app.request("POST", `/api/v1/orgs/${orgId}/admin/search/reindex`, { headers: authHeaders(tok) }); expect(res.status).toBe(201); const body = (await json(res)) as { jobId?: string; status?: string }; expect(body.status).toBe("processing"); expect(body.jobId).toBeTruthy(); } const jobs = await drainSearchIndexQueue(); expect(jobs.length).toBe(2); for (const j of jobs) { expect(j.data).toMatchObject({ type: "reindex", orgId }); expect(j.data).not.toHaveProperty("workspaceId"); } // the trigger is audited (sensitive, expensive op) // The trigger is audited (sensitive, expensive op). Org routes are // workspace-independent, so filter by action + org (the house pattern // used by the rest of the org-admin spec). const audits = await app.prisma.auditLog.findMany({ where: { action: "search.reindex_requested", resourceId: orgId }, orderBy: { createdAt: "desc" } }); expect(audits.length).toBe(2); expect(audits[0]?.actorId).toBeDefined(); expect(audits[0]?.resourceType).toBe("organization"); }); it("workspaceId of ANOTHER org → 404 (no-leak); own org → 201 + scoped job", async () => { const owner = await createUser(app.request, { name: "RX2 Owner" }); const other = await createUser(app.request, { name: "RX2 Other" }); const org1 = await makeOrg(owner.accessToken!); const org2 = await makeOrg(other.accessToken!); // A workspace that belongs to org2 is invisible to org1's admin. const wsRes = await app.request("POST", "/api/v1/workspaces", { headers: authHeaders(other.accessToken!), body: { name: "RX2 WS" } }); expect(wsRes.status).toBe(201); const ws = (await json(wsRes)) as { id: string }; // Bind it to org2 (personal workspaces have null organizationId by default). await app.prisma.workspace.update({ where: { id: ws.id }, data: { organizationId: org2 } }); const denied = await app.request("POST", `/api/v1/orgs/${org1}/admin/search/reindex?workspaceId=${ws.id}`, { headers: authHeaders(owner.accessToken!) }); expect(denied.status).toBe(404); // A workspace of the SAME org is accepted; the job carries BOTH ids. const ownWsRes = await app.request("POST", "/api/v1/workspaces", { headers: authHeaders(owner.accessToken!), body: { name: "RX2 Own WS" } }); const ownWs = (await json(ownWsRes)) as { id: string }; await app.prisma.workspace.update({ where: { id: ownWs.id }, data: { organizationId: org1 } }); const ok = await app.request("POST", `/api/v1/orgs/${org1}/admin/search/reindex?workspaceId=${ownWs.id}`, { headers: authHeaders(owner.accessToken!) }); expect(ok.status).toBe(201); const body = (await json(ok)) as { workspaceId?: string }; expect(body.workspaceId).toBe(ownWs.id); const jobs = await drainSearchIndexQueue(); const scoped = jobs.find((j) => (j.data.workspaceId as string) === ownWs.id); expect(scoped).toBeTruthy(); expect(scoped?.data).toMatchObject({ type: "reindex", orgId: org1, workspaceId: ownWs.id }); }); });