Files
CapaKraken/packages/api/src/__tests__/sse-subscription-policy.test.ts
T

164 lines
4.6 KiB
TypeScript

import { PermissionKey, SSE_EVENT_TYPES, SystemRole } from "@capakraken/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cancelPendingEvents,
eventBus,
permissionAudience,
resourceAudience,
type SseEvent,
userAudience,
} from "../sse/event-bus.js";
import { deriveUserSseSubscription } from "../sse/subscription-policy.js";
vi.mock("ioredis", () => {
const RedisMock = vi.fn().mockImplementation(() => ({
on: vi.fn(),
subscribe: vi.fn().mockResolvedValue(undefined),
publish: vi.fn().mockImplementation(() => {
throw new Error("Redis unavailable (test)");
}),
}));
return { Redis: RedisMock };
});
describe("sse subscription policy", () => {
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
});
afterEach(() => {
cancelPendingEvents();
consoleWarnSpy.mockRestore();
vi.useRealTimers();
});
it("derives canonical user, resource, and permission audiences server-side", () => {
const subscription = deriveUserSseSubscription({
userId: "user_1",
systemRole: SystemRole.USER,
resourceId: "res_1",
permissionOverrides: {
granted: [PermissionKey.VIEW_PLANNING, PermissionKey.MANAGE_ALLOCATIONS],
},
});
expect(subscription.includeUnscoped).toBe(false);
expect(subscription.permissions).toEqual(new Set([
PermissionKey.VIEW_PLANNING,
PermissionKey.MANAGE_ALLOCATIONS,
]));
expect(subscription.audiences).toEqual([
permissionAudience(PermissionKey.MANAGE_ALLOCATIONS),
permissionAudience(PermissionKey.VIEW_PLANNING),
resourceAudience("res_1"),
userAudience("user_1"),
]);
});
it("does not deliver planning events to a standard user without the matching audience", () => {
const received: SseEvent[] = [];
const unsubscribe = eventBus.subscribe(
(event) => {
received.push(event);
},
deriveUserSseSubscription({
userId: "user_1",
systemRole: SystemRole.USER,
resourceId: "res_self",
}),
);
eventBus.emit(
SSE_EVENT_TYPES.ALLOCATION_UPDATED,
{ id: "allocation_1", resourceId: "res_other" },
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS), resourceAudience("res_other")],
);
vi.advanceTimersByTime(50);
expect(received).toHaveLength(0);
unsubscribe();
});
it("does not leak personal notifications to a manager subscribed with elevated permissions", () => {
const received: SseEvent[] = [];
const unsubscribe = eventBus.subscribe(
(event) => {
received.push(event);
},
deriveUserSseSubscription({
userId: "manager_1",
systemRole: SystemRole.MANAGER,
}),
);
eventBus.emit(
SSE_EVENT_TYPES.NOTIFICATION_CREATED,
{ notificationId: "notification_1", userId: "user_2" },
[userAudience("user_2")],
);
vi.advanceTimersByTime(50);
expect(received).toHaveLength(0);
unsubscribe();
});
it("delivers a multi-audience planning event only to matching manager and affected resource subscribers", () => {
const managerReceived: SseEvent[] = [];
const affectedUserReceived: SseEvent[] = [];
const unrelatedUserReceived: SseEvent[] = [];
const unsubscribeManager = eventBus.subscribe(
(event) => {
managerReceived.push(event);
},
deriveUserSseSubscription({
userId: "manager_1",
systemRole: SystemRole.MANAGER,
}),
);
const unsubscribeAffectedUser = eventBus.subscribe(
(event) => {
affectedUserReceived.push(event);
},
deriveUserSseSubscription({
userId: "user_1",
systemRole: SystemRole.USER,
resourceId: "res_1",
}),
);
const unsubscribeUnrelatedUser = eventBus.subscribe(
(event) => {
unrelatedUserReceived.push(event);
},
deriveUserSseSubscription({
userId: "user_2",
systemRole: SystemRole.USER,
resourceId: "res_2",
}),
);
eventBus.emit(
SSE_EVENT_TYPES.ALLOCATION_UPDATED,
{ id: "allocation_1", resourceId: "res_1" },
[permissionAudience(PermissionKey.MANAGE_ALLOCATIONS), resourceAudience("res_1")],
);
vi.advanceTimersByTime(50);
expect(managerReceived).toHaveLength(1);
expect(affectedUserReceived).toHaveLength(1);
expect(unrelatedUserReceived).toHaveLength(0);
unsubscribeManager();
unsubscribeAffectedUser();
unsubscribeUnrelatedUser();
});
});