/** * T-158 — Activity feeds (permission-scoped projections over audit_logs). * * - GET /users/me/activity: the caller's actions (any space) + rows of the * spaces they belong to; a second space's rows never leak in * - GET /workspaces/:id/activity: membership-gated (non-member → 403), * newest-first, keyset cursor pagination * - rows carry actorName for USER actors (the feed's "who" label) */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 60_000); afterAll(async () => { await app?.close(); }); async function mkPage(wsId: string, headers: Record, title: string): Promise { const res = await app.request("POST", `/api/v1/workspaces/${wsId}/pages`, { headers, body: { title } }); expect(res.status).toBe(201); return ((await res.json()) as { id: string }).id; } interface ActivityRow { id: string; action: string; actorId: string; actorName: string | null; workspaceId: string | null; pageId: string | null; createdAt: string; } describe("T-158: activity feeds", () => { it("my activity includes my actions + my spaces' rows; other spaces don't leak", async () => { const owner = await createUser(app.request, { name: "Act Owner" }); const other = await createUser(app.request, { name: "Act Other" }); const h = authHeaders(owner.accessToken!); const ho = authHeaders(other.accessToken!); const ws1 = await createWorkspace(app.request, h, "Act WS1"); const ws2 = await createWorkspace(app.request, ho, "Act WS2"); await mkPage(ws1.workspaceId, h, "Visible to owner"); await mkPage(ws2.workspaceId, ho, "Other's page"); const res = await app.request("GET", "/api/v1/users/me/activity", { headers: h }); expect(res.status).toBe(200); const body = (await res.json()) as { data: ActivityRow[] }; const actions = body.data.map((r) => r.action); // the owner's own page.created (in ws1) is present expect(actions).toContain("page.created"); const created = body.data.find((r) => r.action === "page.created")!; expect(created.actorId).toBe(owner.userId); expect(created.actorName).toBe("Act Owner"); expect(created.workspaceId).toBe(ws1.workspaceId); // NOTHING from ws2 (a space the owner does not belong to) expect(body.data.every((r) => r.workspaceId !== ws2.workspaceId)).toBe(true); // pagination: a small page + a valid nextCursor that returns the rest const page1 = (await (await app.request("GET", "/api/v1/users/me/activity?limit=1", { headers: h })).json()) as { data: ActivityRow[]; nextCursor: string }; expect(page1.data).toHaveLength(1); expect(page1.nextCursor).toBeTruthy(); const page2 = (await (await app.request("GET", `/api/v1/users/me/activity?limit=1&cursor=${encodeURIComponent(page1.nextCursor!)}`, { headers: h })).json()) as { data: ActivityRow[] }; expect(page2.data).toHaveLength(1); expect(page2.data[0]?.id).not.toBe(page1.data[0]?.id); }); it("workspace activity is membership-gated + lists the space's rows", async () => { const owner = await createUser(app.request, { name: "WS Act Owner" }); const member = await createUser(app.request, { name: "WS Act Member" }); const outsider = await createUser(app.request, { name: "WS Act Outsider" }); const h = authHeaders(owner.accessToken!); const hm = authHeaders(member.accessToken!); const ws = await createWorkspace(app.request, h, "WS Act"); await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER"); const pageId = await mkPage(ws.workspaceId, h, "Act Page"); // member can read the space feed; sees the owner's page.created const res = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/activity`, { headers: hm }); expect(res.status).toBe(200); const body = (await res.json()) as { data: ActivityRow[] }; const created = body.data.find((r) => r.action === "page.created" && r.pageId === pageId); expect(created).toBeTruthy(); expect(created!.actorName).toBe("WS Act Owner"); // outsider → 403 (no-leak, same contract as stats/archive) const denied = await app.request("GET", `/api/v1/workspaces/${ws.workspaceId}/activity`, { headers: authHeaders(outsider.accessToken!) }); expect(denied.status).toBe(403); }); });