Files
suaveplan/e2e/foundation-stack-engines/tests/ws-tenant-isolation.spec.ts
2026-05-27 23:29:53 +01:00

123 lines
3.7 KiB
TypeScript

import { expect, test } from "@playwright/test";
/**
* Stage-7 closure smoke — `api-ws-bun` tenant-scoped broadcast.
*
* Two clients connect to `/ws` on different tenants (`tenant-a` /
* `tenant-b`) and subscribe to the SAME topic (`updates`). A publish
* scoped to `tenant-a` MUST reach only the tenant-a client; the
* tenant-b client MUST NOT see it. This proves the
* `SubscriptionBus.broadcast` tenant key — design §11.4 — is honoured
* by the engine's `broadcast(...)` helper.
*/
interface WsConnection {
readonly ws: WebSocket;
readonly received: unknown[];
close(): void;
}
async function openWs(
baseURL: string,
tenant: string,
topic: string
): Promise<WsConnection> {
const wsUrl = baseURL.replace(/^http/, "ws");
const ws = new WebSocket(
`${wsUrl}/ws?tenant=${encodeURIComponent(tenant)}&topic=${encodeURIComponent(topic)}`
);
const received: unknown[] = [];
ws.addEventListener("message", (ev) => {
try {
received.push(JSON.parse(String(ev.data)));
} catch {
received.push(ev.data);
}
});
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error("ws open timeout")),
5000
);
ws.addEventListener(
"open",
() => {
clearTimeout(timer);
resolve();
},
{ once: true }
);
ws.addEventListener(
"error",
(e) => {
clearTimeout(timer);
reject(new Error(`ws error: ${String(e)}`));
},
{ once: true }
);
});
return {
ws,
received,
close() {
try {
ws.close();
} catch {
/* idempotent */
}
},
};
}
async function settle(ms = 200): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
test.describe("foundation-stack-engines — ws-bun tenant isolation", () => {
test("3. tenant-A broadcast reaches only tenant-A subscriber, never tenant-B", async ({
request,
baseURL,
}) => {
expect(baseURL).toBeDefined();
const clientA = await openWs(baseURL as string, "tenant-a", "updates");
const clientB = await openWs(baseURL as string, "tenant-b", "updates");
try {
// Let the engine.attach + subscribe calls settle.
await settle(150);
const publish = await request.post("/ws/publish", {
data: {
topic: "updates",
tenant: "tenant-a",
payload: { hello: "from-tenant-a" },
},
});
expect(publish.status()).toBe(200);
const pBody = (await publish.json()) as { delivered: number };
// Exactly one subscriber (the tenant-a client) should match.
expect(pBody.delivered).toBe(1);
// Allow the send pipeline to flush over the socket.
await settle(300);
expect(clientA.received.length).toBe(1);
const env = clientA.received[0] as {
type?: string;
topic?: string;
payload?: Record<string, unknown>;
};
expect(env.type).toBe("broadcast");
expect(env.topic).toBe("updates");
expect(env.payload).toEqual({ hello: "from-tenant-a" });
// Tenant-B subscriber must NOT have received the tenant-A
// broadcast — proves cross-tenant isolation.
expect(clientB.received.length).toBe(0);
} finally {
clientA.close();
clientB.close();
}
});
});