/** * FabriChat's rooms under an enforced access list, across runtimes and under * either server-execution posture: a manager creates each room in a space of * its own and joins its user to it, and the room's members, and no one else, * can read it, unless it is a group made joinable by its link, which anyone * can read. * * Three sessions share the harness's space, where the manager lives: the * starter, who creates the rooms; a member, named in each; and a stranger, * named in none. A room's space grants its members at creation, so the member * reads it with nothing more to do, and the stranger's read is refused, except * of a joinable group's. * `fabrichat-manager.test.ts` checks the access list each room's space holds. * * No toolshed or browser required (Deno workers + in-process storage server). */ import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { join } from "@std/path"; import type { FabricValue } from "@commonfabric/data-model"; import { MultiRuntimeHarness, type MultiRuntimeSession, type PieceAddress, } from "./multi-runtime-harness.ts"; const PROGRAM_PATH = join( import.meta.dirname!, "fixtures", "fabrichat-spaces", "main.tsx", ); const ROOT_PATH = join(import.meta.dirname!, ".."); // The reviewed action a start is admitted from, as // `../fabrichat/schemas.tsx` names it. const START_ACTION = { surface: "ChatStartSurface", action: "ChatStart" }; // The reviewed action a member is added from, as `../fabrichat/schemas.tsx` // names it. const ADD_MEMBER_ACTION = { surface: "ChatAddMemberSurface", action: "ChatAddMember", }; describe("fabrichat spaces across runtimes", () => { let harness: MultiRuntimeHarness; let starter: MultiRuntimeSession; let member: MultiRuntimeSession; let stranger: MultiRuntimeSession; beforeAll(async () => { harness = await MultiRuntimeHarness.create({ programPath: PROGRAM_PATH, rootPath: ROOT_PATH, sessions: [ "fabrichat-spaces-starter", "fabrichat-spaces-member", "fabrichat-spaces-stranger", ], aclMode: "enforce", }); [starter, member, stranger] = harness.sessions; await harness.settle(); }); afterAll(async () => { await harness?.dispose(); }); /** * Has the starter send `event` on the manager's `stream` from its reviewed * start control, checks that the request was done and that the manager * joined the starter to the room it produced, and returns that room's * address. */ async function start( stream: "openDirect" | "createGroup", event: Record & { requestId: string }, ): Promise { await starter.send(stream, event, START_ACTION); await harness.settle(); expect(await starter.read(["requests", event.requestId, "status"])) .toBe("done"); const room = await starter.link([ "requests", event.requestId, "entry", "room", ]); // The room starts with no participants, and holds no messages whose // authors it would add, so the one it lists is the starter's join. Under // server execution the join is an event the served start emits, which // commits in a later wave than the start's own, and `settle()` waits only // for the start's; so the wait is for the room to list anyone, and the // assertions then say who. await harness.settleUntil(async () => (await starter.read(["participants", "length"], { piece: room })) !== 0 ); expect(await starter.read(["participants", "length"], { piece: room })) .toBe(1); expect(await starter.read(["participants", 0, "name"], { piece: room })) .toBe("Starter"); return room; } it("lets a group room's member read it, and refuses a stranger", async () => { const room = await start("createGroup", { requestId: "g-1", title: "Team", members: [member.identity.did()], }); expect(room.space).not.toBe(harness.spaceDid); expect(await member.read(["about", "title"], { piece: room })) .toBe("Team"); await expect(stranger.read(["about", "title"], { piece: room })).rejects .toThrow(`lacks READ on space ${room.space}`); }); it("reads a group room's message count through the manager's link, and starts a room after it", async () => { // The link is part of every manager handler's declared reads, so a room // started with one already listed shows that the link reaches nothing a // served handler may not read. await start("createGroup", { requestId: "g-count", title: "Counted", members: [member.identity.did()], }); expect( await starter.read([ "requests", "g-count", "entry", "room", "messages", "count", ]), ).toBe(0); // The member, who started nothing, reads the same count through the // starter's manager, in either posture. expect( await member.read([ "requests", "g-count", "entry", "room", "messages", "count", ]), ).toBe(0); await start("createGroup", { requestId: "g-count-next", title: "Counted next", members: [member.identity.did()], }); }); it("lets anyone read a group room made joinable by its link", async () => { const room = await start("createGroup", { requestId: "g-open", title: "Open team", members: [member.identity.did()], joinableByLink: true, }); expect(await stranger.read(["about", "title"], { piece: room })) .toBe("Open team"); }); it("lets a group room's member add a stranger from the room's add control", async () => { const room = await start("createGroup", { requestId: "g-add", title: "Growing team", members: [member.identity.did()], }); const adding = { target: { value: stranger.identity.did() } }; // Sent as anything but the person's gesture, the add admits no one. await member.send("addMember", adding, undefined, { piece: room }); await harness.settle(); await expect(stranger.read(["about", "title"], { piece: room })).rejects .toThrow(`lacks READ on space ${room.space}`); // A member admitted at creation holds OWNER, so their own add, from the // room's control, admits the stranger, and the room records it as done // where the member's own views read it. No session here has a chat // manager to tell the stranger of the room, so the add reads as access // alone. await member.send( "addMember", { ...adding, requestId: "add-stranger" }, ADD_MEMBER_ACTION, { piece: room }, ); await harness.settle(); expect(await stranger.read(["about", "title"], { piece: room })) .toBe("Growing team"); expect( await member.read(["$VIEWS", "room", "addRequests", "add-stranger"], { piece: room, }), ).toEqual({ status: "done", delivery: "access-only" }); }); it("refuses an add to a direct room, even from its counterpart's own control", async () => { const room = await start("openDirect", { requestId: "d-add", counterpart: member.identity.did(), }); const participants = await member.read(["participants", "length"], { piece: room, }); // The counterpart holds OWNER, as a member admitted at creation does, and // sends from the room's control, yet a direct room keeps its two members. // Its views say they can't add, and the refusal carries its code. expect( await member.read(["$VIEWS", "room", "canAdd"], { piece: room }), ).toBe(false); await member.send( "addMember", { requestId: "add-to-direct", target: { value: stranger.identity.did() }, }, ADD_MEMBER_ACTION, { piece: room }, ); await harness.settle(); expect( await member.read(["$VIEWS", "room", "addRequests", "add-to-direct"], { piece: room, }), ).toEqual({ status: "refused", reason: "A direct chat keeps its two members.", code: "direct-room", }); await expect(stranger.read(["about", "kind"], { piece: room })).rejects .toThrow(`lacks READ on space ${room.space}`); expect(await member.read(["participants", "length"], { piece: room })) .toBe(participants); }); it("lets a direct room's counterpart read it, and refuses a stranger", async () => { const room = await start("openDirect", { requestId: "d-1", counterpart: member.identity.did(), }); expect(room.space).not.toBe(harness.spaceDid); expect(await member.read(["about", "kind"], { piece: room })) .toBe("direct"); await expect(stranger.read(["about", "kind"], { piece: room })).rejects .toThrow(`lacks READ on space ${room.space}`); }); });