123 lines
3.7 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|