Initial Commit
This commit is contained in:
122
e2e/foundation-stack-engines/tests/ws-tenant-isolation.spec.ts
Normal file
122
e2e/foundation-stack-engines/tests/ws-tenant-isolation.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user