import { EventEmitter } from "node:events"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; /** * Ticket #57 — verify that: * * 1. Publishing on RBAC_INVALIDATE_CHANNEL from node A causes node B to * drop its local `_roleDefaultsCache`, so its next `loadRoleDefaults()` * call re-reads from the DB (acceptance criterion: * "2nd node sees update within 1 s" — we verify the mechanism, not the * Redis latency). * * 2. `invalidateRoleDefaultsCache()` on the current node publishes on the * same channel so peer instances receive the event. * * Strategy: stub `ioredis` with an EventEmitter-based fake before loading * trpc.ts. The fake captures `publish()` calls and lets the test emit * synthetic "message" events. */ // Fake Redis with two separate instances so the test mirrors the multi-node // shape: one as subscriber, one as publisher. Both share the same module- // level event router keyed by channel. const channelSubscribers = new Map>(); const publishCalls: Array<{ channel: string; message: string }> = []; class FakeRedis extends EventEmitter { constructor(_url: string, _opts: unknown) { super(); } // eslint-disable-next-line @typescript-eslint/require-await async subscribe(channel: string): Promise { let set = channelSubscribers.get(channel); if (!set) { set = new Set(); channelSubscribers.set(channel, set); } set.add(this); return set.size; } // eslint-disable-next-line @typescript-eslint/require-await async publish(channel: string, message: string): Promise { publishCalls.push({ channel, message }); const subs = channelSubscribers.get(channel); if (!subs) return 0; // Fan out synchronously so the subscriber handler runs before the test // assertion reads the cache — matches real ioredis "message" semantics // from the subscriber's point of view. for (const sub of subs) sub.emit("message", channel, message); return subs.size; } } vi.mock("ioredis", () => ({ Redis: FakeRedis, default: FakeRedis })); vi.mock("../lib/logger.js", () => ({ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); // Prisma client mock — loadRoleDefaults pulls from systemRoleConfig.findMany. const findManyCalls: number[] = []; vi.mock("@capakraken/db", async () => { const actual = await vi.importActual>("@capakraken/db"); return { ...actual, prisma: { systemRoleConfig: { findMany: vi.fn().mockImplementation(async () => { findManyCalls.push(Date.now()); return [{ role: "ADMIN", defaultPermissions: ["MANAGE_USERS"] }]; }), }, }, }; }); // REDIS_URL is needed so trpc.ts decides to instantiate the fake Redis. // `trpc.ts` now reads it lazily on first RBAC call, so setting it in // beforeAll is enough; we always restore in afterAll to avoid leaking into // other test files in the same worker. const originalRedisUrl = process.env["REDIS_URL"]; describe("RBAC cache Redis pub/sub (#57)", () => { beforeAll(() => { process.env["REDIS_URL"] = "redis://fake:6379"; }); afterAll(() => { if (originalRedisUrl === undefined) delete process.env["REDIS_URL"]; else process.env["REDIS_URL"] = originalRedisUrl; }); beforeEach(() => { findManyCalls.length = 0; }); it("peer-instance invalidation: receiving a message clears the local cache", async () => { const { loadRoleDefaults } = await import("../trpc.js"); // Warm the cache. await loadRoleDefaults(); const hitsAfterWarm = findManyCalls.length; expect(hitsAfterWarm).toBe(1); // Second call within TTL should be cached — no additional findMany. await loadRoleDefaults(); expect(findManyCalls.length).toBe(hitsAfterWarm); // Simulate a peer instance publishing an invalidation: grab any // subscriber on the channel and fire the event as if Redis delivered it. const subs = channelSubscribers.get("capakraken:rbac-invalidate"); expect(subs).toBeDefined(); expect(subs!.size).toBeGreaterThanOrEqual(1); for (const sub of subs!) sub.emit("message", "capakraken:rbac-invalidate", "1"); // Next load must hit the DB again. await loadRoleDefaults(); expect(findManyCalls.length).toBe(hitsAfterWarm + 1); }); it("local invalidation publishes on the RBAC channel", async () => { const { invalidateRoleDefaultsCache } = await import("../trpc.js"); const countBefore = publishCalls.length; invalidateRoleDefaultsCache(); // Give the microtask queue one tick (publish returns a promise). await Promise.resolve(); const newPublishes = publishCalls.slice(countBefore); expect(newPublishes.length).toBe(1); expect(newPublishes[0]!.channel).toBe("capakraken:rbac-invalidate"); }); });