87 lines
3.6 KiB
TypeScript
87 lines
3.6 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
import Redis from "ioredis";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("server-only", () => ({}));
|
|
|
|
import { closeRedisEventStreams, createRedisEventResponse } from "@/lib/events/redis-stream";
|
|
import { updateVisitorPresence } from "@/lib/presence";
|
|
import { closeRedisClient, redisEventChannel } from "@/lib/redis/client";
|
|
import { consumeRateLimit } from "@/lib/security/rate-limit";
|
|
|
|
const integrationUrl = process.env.REDIS_INTEGRATION_URL;
|
|
const describeWithRedis = integrationUrl ? describe : describe.skip;
|
|
|
|
describeWithRedis("shared Redis security paths", () => {
|
|
const runId = randomUUID();
|
|
const original = {
|
|
url: process.env.REDIS_URL,
|
|
event: process.env.REDIS_EVENT_PREFIX,
|
|
presence: process.env.REDIS_PRESENCE_PREFIX,
|
|
security: process.env.REDIS_SECURITY_PREFIX,
|
|
};
|
|
let publisher: Redis;
|
|
|
|
beforeAll(async () => {
|
|
process.env.REDIS_URL = integrationUrl;
|
|
process.env.REDIS_EVENT_PREFIX = `buzz:test:${runId}:events`;
|
|
process.env.REDIS_PRESENCE_PREFIX = `buzz:test:${runId}:presence`;
|
|
process.env.REDIS_SECURITY_PREFIX = `buzz:test:${runId}:security`;
|
|
publisher = new Redis(integrationUrl!);
|
|
await publisher.ping();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
closeRedisEventStreams();
|
|
await closeRedisClient();
|
|
if (publisher) {
|
|
const keys = await publisher.keys(`buzz:test:${runId}:*`);
|
|
if (keys.length) await publisher.del(...keys);
|
|
await publisher.quit();
|
|
}
|
|
for (const [key, value] of Object.entries(original)) {
|
|
const envName = { url: "REDIS_URL", event: "REDIS_EVENT_PREFIX", presence: "REDIS_PRESENCE_PREFIX", security: "REDIS_SECURITY_PREFIX" }[key]!;
|
|
if (value === undefined) delete process.env[envName];
|
|
else process.env[envName] = value;
|
|
}
|
|
});
|
|
|
|
it("counts requests atomically across callers", async () => {
|
|
const results = await Promise.all(Array.from({ length: 8 }, () => consumeRateLimit(`test:${runId}`, { window: 60, max: 5 })));
|
|
expect(results.filter((result) => result.allowed)).toHaveLength(5);
|
|
expect(results.filter((result) => !result.allowed)).toHaveLength(3);
|
|
});
|
|
|
|
it("tracks visitor counts without a separate Redis round trip per visitor", async () => {
|
|
const first = await updateVisitorPresence({ tabId: `${runId}:1`, guideId: "guide-a", active: true });
|
|
expect(first.global).toBe(1);
|
|
expect(first.guide).toBe(1);
|
|
await updateVisitorPresence({ tabId: `${runId}:2`, guideId: "guide-a", active: true });
|
|
const count = await publisher.zcard(`${process.env.REDIS_PRESENCE_PREFIX}:visitors`);
|
|
expect(count).toBe(2);
|
|
});
|
|
|
|
it("fans one subscribed topic out to two SSE clients", async () => {
|
|
const topic = `page:${runId}`;
|
|
const first = new AbortController();
|
|
const second = new AbortController();
|
|
const [responseA, responseB] = await Promise.all([
|
|
createRedisEventResponse(topic, first.signal),
|
|
createRedisEventResponse(topic, second.signal),
|
|
]);
|
|
const readerA = responseA.body!.getReader();
|
|
const readerB = responseB.body!.getReader();
|
|
await Promise.all([readerA.read(), readerB.read()]);
|
|
const payload = JSON.stringify({ type: "page.updated", id: runId, version: 1 });
|
|
await publisher.publish(redisEventChannel(topic), payload);
|
|
const [a, b] = await Promise.all([readerA.read(), readerB.read()]);
|
|
const decoder = new TextDecoder();
|
|
expect(decoder.decode(a.value)).toContain(payload);
|
|
expect(decoder.decode(b.value)).toContain(payload);
|
|
first.abort();
|
|
second.abort();
|
|
await Promise.all([readerA.cancel(), readerB.cancel()]);
|
|
});
|
|
});
|