diff --git a/src/test/room-lifecycle-membership.test.ts b/src/test/room-lifecycle-membership.test.ts new file mode 100644 index 0000000..8607dc8 --- /dev/null +++ b/src/test/room-lifecycle-membership.test.ts @@ -0,0 +1,820 @@ +/** + * Direct, DI-based unit tests for RoomLifecycle's membership-grant half -- leaveRoom/leaveRemoteRoom, inviteToRoom, kickFromRoom, and destroyRoom. Split from room-lifecycle.test.ts (createRoom/listRooms/joinRoom) and room-lifecycle-remote.test.ts (refreshRoomMembers/requestDmAccess/getRoom/revokeMemberGrant/declineInvite) to stay under this repo's max-lines cap. All three files share an identical preamble (helpers, fakes, makeHarness) by necessity of the split -- see room-lifecycle.test.ts's own header for the full rationale (real identities/tokens, not opaque placeholders, since this class genuinely mints and revokes capability tokens). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { tmpdir } from "node:os"; +import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id"; +import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; +import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; +import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; +import type { ManageOutcome } from "wire-mesh-core/domain/mesh-session"; +import { generateIdentity } from "../core/identity.js"; +import { + loadOrCreateIdentity, + loadIssuedRoomGrant, + loadRoomTokens, + saveIssuedRoomGrant, + saveRoomToken, +} from "../core/identity-store.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { dmRoomPath, ownerNamedRoomPath } from "../core/room-path.js"; +import { randomId } from "../core/random-id.js"; +import { + RoomLifecycle, + type RoomLifecycleDeps, +} from "../core/room-lifecycle.js"; +import type { AgentIdentity, Room } from "../core/types.js"; + +const TOKEN_TTL_MS = 60_000; + +function room(overrides: Partial = {}): Room { + return { + id: "room-1", + version: 1, + name: "room-name", + type: "public", + owner: "", + createdAt: "2026-01-01T00:00:00.000Z", + description: "a room", + members: [], + invited: [], + memberJoins: {}, + memberLeaves: {}, + invitedJoins: {}, + invitedLeaves: {}, + ...overrides, + }; +} + +function agent(overrides: Partial = {}): AgentIdentity { + return { + id: "", + version: 1, + name: "agent-name", + harness: "pi", + cwd: "/tmp/agent", + pid: 1, + startedAt: "2026-01-01T00:00:00.000Z", + visibility: "visible", + status: "active", + tags: [], + subscribedRooms: [], + ...overrides, + }; +} + +interface Identities { + ownerId: string; + ownerPort: Awaited>; + memberId: string; +} + +async function makeIdentities(): Promise { + const owner = generateIdentity(); + const member = generateIdentity(); + return { + ownerId: bytesToHex(Uint8Array.from(owner.deviceId)), + ownerPort: await toIdentityPort(owner), + memberId: bytesToHex(Uint8Array.from(member.deviceId)), + }; +} + +async function mintRoomToken( + issuerPort: Awaited>, + bearerHex: string, + roomPath: string, +): Promise { + const clock = createSystemClock(); + const verdict = await mintCapabilityToken({ + identity: issuerPort, + clock, + tokenId: randomId(), + bearer: deviceIdFromHex(bearerHex), + capability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + if (!verdict.ok) + throw new Error("expected the fixture token to mint successfully"); + return verdict.token; +} + +interface Harness { + deps: RoomLifecycleDeps; + lifecycle: RoomLifecycle; + ids: Identities; + slotDir: string; + bump: RoomLifecycleDeps["deliveryEngine"]["bump"]; + recordMemberOp: ReturnType; + refreshMembership: ReturnType; + broadcastPatch: ReturnType; + deliverToRoom: ReturnType; + deliverLocallyAndBroadcast: ReturnType; + broadcastRoomJoin: ReturnType; + broadcastRoomLeave: ReturnType; + sendRoomRequest: ReturnType; + broadcastRevocation: ReturnType; +} + +async function makeHarness(): Promise { + const ids = await makeIdentities(); + const slotDir = fs.mkdtempSync( + path.join(tmpdir(), "room-lifecycle-membership-test-"), + ); + const slot = { harness: "test", cwd: "room-lifecycle", dir: slotDir }; + // saveRoomToken/saveIssuedRoomGrant write into this slot's own persisted identity file, which must already exist on disk -- unrelated to which crypto identity requireIdentity() uses for minting/verifying, purely a filesystem bookkeeping precondition. + loadOrCreateIdentity(slot); + + const bump = vi.fn((readonlyEntity: Readonly<{ version: number }>) => { + const entity = readonlyEntity as { version: number }; + entity.version += 1; + return entity; + }) as unknown as RoomLifecycleDeps["deliveryEngine"]["bump"]; + const recordMemberOp = vi.fn< + RoomLifecycleDeps["deliveryEngine"]["recordMemberOp"] + >((r, list, op, agentId) => { + const joins = list === "member" ? r.memberJoins : r.invitedJoins; + const leaves = list === "member" ? r.memberLeaves : r.invitedLeaves; + if (op === "join") joins[agentId] = r.version; + else leaves[agentId] = r.version; + }); + const refreshMembership = vi.fn< + RoomLifecycleDeps["deliveryEngine"]["refreshMembership"] + >((r) => { + r.members = Object.keys(r.memberJoins).filter( + (id) => (r.memberJoins[id] ?? 0) > (r.memberLeaves[id] ?? 0), + ); + r.invited = Object.keys(r.invitedJoins).filter( + (id) => (r.invitedJoins[id] ?? 0) > (r.invitedLeaves[id] ?? 0), + ); + }); + const broadcastPatch = vi.fn().mockResolvedValue(undefined); + const deliverToRoom = vi.fn().mockResolvedValue(undefined); + const deliverLocallyAndBroadcast = vi.fn().mockResolvedValue(undefined); + const broadcastRoomJoin = vi.fn().mockResolvedValue(undefined); + const broadcastRoomLeave = vi.fn().mockResolvedValue(undefined); + const broadcastRevocation = vi.fn().mockResolvedValue(undefined); + const sendRoomRequest = vi.fn().mockResolvedValue({ + result: "error", + code: "not_connected", + } satisfies ManageOutcome); + + const deps: RoomLifecycleDeps = { + rooms: new Map(), + messages: new Map(), + agents: new Map(), + dmRequestsInitiatedByMe: new Set(), + getPeerId: () => ids.ownerId, + requireIdentity: () => ({ + identity: ids.ownerPort, + clock: createSystemClock(), + slot, + revocation: createRevocationView(), + }), + requireTransport: () => + ({ sendRoomRequest, broadcastRevocation }) as unknown as ReturnType< + RoomLifecycleDeps["requireTransport"] + >, + deliveryEngine: { + bump, + recordMemberOp, + refreshMembership, + broadcastPatch, + deliverToRoom, + deliverLocallyAndBroadcast, + }, + federation: { broadcastRoomJoin, broadcastRoomLeave }, + }; + + return { + deps, + lifecycle: new RoomLifecycle(deps), + ids, + slotDir, + bump, + recordMemberOp, + refreshMembership, + broadcastPatch, + deliverToRoom, + deliverLocallyAndBroadcast, + broadcastRoomJoin, + broadcastRoomLeave, + sendRoomRequest, + broadcastRevocation, + }; +} + +/** Persists an issued-grant record for memberId in roomPath as if this store's own identity had genuinely admitted them (invite or join), via the same real crypto path inviteToRoom itself uses -- revokeMemberGrant/kickFromRoom/destroyRoom all read this record by its own token-id, not anything derivable from the token or the in-memory Room object afterward. */ +async function seedIssuedGrant( + h: Harness, + roomPath: string, + memberId: string, +): Promise { + const { slot } = h.deps.requireIdentity(); + const tokenId = randomId(); + const clock = createSystemClock(); + const verdict = await mintCapabilityToken({ + identity: h.ids.ownerPort, + clock, + tokenId, + bearer: deviceIdFromHex(memberId), + capability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + if (!verdict.ok) + throw new Error("expected the fixture token to mint successfully"); + saveIssuedRoomGrant(slot, roomPath, memberId, tokenId); +} + +// Two mutants Stryker still raises here are left undocumented-but-untested, both genuinely unreachable with real crypto: inviteToRoom's own `this.deps.rooms.set(roomId, room)` call is a same-object-reference no-op (the identical pattern documented at the top of room-lifecycle.test.ts's joinRoom block), and its `if (!verdict.ok)` MINT_FAILED branch cannot fail against a validly-generated identity for the same reason mintOwnerRootGrant's own failure branch can't (documented at the top of room-lifecycle.test.ts's createRoom block). +describe("RoomLifecycle — inviteToRoom", () => { + it("throws ROOM_NOT_FOUND for an unknown room", async () => { + const h = await makeHarness(); + await expect( + h.lifecycle.inviteToRoom("no-such-room", h.ids.memberId, h.ids.ownerId), + ).rejects.toMatchObject({ + message: "Room no-such-room not found", + code: "ROOM_NOT_FOUND", + }); + }); + + it("throws NOT_OWNER when the inviter isn't the room's own owner", async () => { + const h = await makeHarness(); + h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId })); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + } satisfies ManageOutcome); + await expect( + h.lifecycle.inviteToRoom("room-1", "some-target", h.ids.memberId), + ).rejects.toMatchObject({ + message: "Only the room owner can invite", + code: "NOT_OWNER", + }); + }); + + it("records an invited-join only for a target not already invited or a member, and bumps the room's version", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ id: "room-1", owner: h.ids.ownerId, version: 1 }), + ); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + } satisfies ManageOutcome); + await h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId); + expect(h.recordMemberOp).toHaveBeenCalledWith( + expect.anything(), + "invited", + "join", + h.ids.memberId, + ); + expect(h.deps.rooms.get("room-1")?.version).toBe(2); + }); + + it("does not re-record an invited-join for a target already invited", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + invited: [h.ids.memberId], + invitedJoins: { [h.ids.memberId]: 1 }, + }), + ); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + } satisfies ManageOutcome); + await h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId); + expect(h.recordMemberOp).not.toHaveBeenCalledWith( + expect.anything(), + "invited", + "join", + h.ids.memberId, + ); + }); + + it("does not record an invited-join for a target who is already a member", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.memberId], + memberJoins: { [h.ids.memberId]: 1 }, + }), + ); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + } satisfies ManageOutcome); + await h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId); + expect(h.recordMemberOp).not.toHaveBeenCalledWith( + expect.anything(), + "invited", + "join", + h.ids.memberId, + ); + }); + + it("persists an issued-grant record for the invited target", async () => { + const h = await makeHarness(); + h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId })); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + } satisfies ManageOutcome); + await h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId); + const { slot } = h.deps.requireIdentity(); + expect(loadIssuedRoomGrant(slot, "room-1", h.ids.memberId)).toBeDefined(); + }); + + it("includes the inviter-agent extension only when the inviter has a local agent record", async () => { + const withInviter = await makeHarness(); + withInviter.deps.rooms.set( + "room-1", + room({ id: "room-1", owner: withInviter.ids.ownerId }), + ); + withInviter.deps.agents.set( + withInviter.ids.ownerId, + agent({ id: withInviter.ids.ownerId, name: "inviter-name" }), + ); + withInviter.sendRoomRequest.mockResolvedValue({ + result: "ok", + } satisfies ManageOutcome); + await withInviter.lifecycle.inviteToRoom( + "room-1", + withInviter.ids.memberId, + withInviter.ids.ownerId, + ); + const params = withInviter.sendRoomRequest.mock.calls[0]?.[1] + ?.params as Record; + expect(params).toHaveProperty("inviter-agent"); + + const withoutInviter = await makeHarness(); + withoutInviter.deps.rooms.set( + "room-1", + room({ id: "room-1", owner: withoutInviter.ids.ownerId }), + ); + withoutInviter.sendRoomRequest.mockResolvedValue({ + result: "ok", + } satisfies ManageOutcome); + await withoutInviter.lifecycle.inviteToRoom( + "room-1", + withoutInviter.ids.memberId, + withoutInviter.ids.ownerId, + ); + const params2 = withoutInviter.sendRoomRequest.mock.calls[0]?.[1] + ?.params as Record; + expect(params2).not.toHaveProperty("inviter-agent"); + }); + + it("throws INVITE_FAILED naming the outcome code on a non-ok outcome", async () => { + const h = await makeHarness(); + h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId })); + h.sendRoomRequest.mockResolvedValue({ + result: "error", + code: "not_reachable", + } satisfies ManageOutcome); + await expect( + h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId), + ).rejects.toMatchObject({ + message: `Invite to ${h.ids.memberId} for room-1 failed (not_reachable)`, + code: "INVITE_FAILED", + }); + }); + + it("resolves without throwing on a genuinely successful invite, sent scoped to the room path", async () => { + const h = await makeHarness(); + h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId })); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + } satisfies ManageOutcome); + await expect( + h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId), + ).resolves.toBeUndefined(); + expect(h.sendRoomRequest).toHaveBeenCalledWith( + h.ids.memberId, + expect.anything(), + { kind: "room", path: "room-1" }, + ); + }); +}); + +// kickFromRoom's own trailing `this.deps.rooms.set(roomId, room)` call is the same same-object-reference no-op documented at the top of room-lifecycle.test.ts's joinRoom block -- `room` is already the map's own stored reference by the time this runs. +describe("RoomLifecycle — kickFromRoom", () => { + it("throws ROOM_NOT_FOUND for an unknown room", async () => { + const h = await makeHarness(); + await expect( + h.lifecycle.kickFromRoom("no-such-room", h.ids.memberId, h.ids.ownerId), + ).rejects.toMatchObject({ + message: "Room no-such-room not found", + code: "ROOM_NOT_FOUND", + }); + }); + + it("throws NOT_OWNER when the kicker isn't the room's own owner", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.memberId], + memberJoins: { [h.ids.memberId]: 1 }, + }), + ); + await expect( + h.lifecycle.kickFromRoom("room-1", h.ids.memberId, "not-the-owner"), + ).rejects.toMatchObject({ + message: "Only the room owner can kick", + code: "NOT_OWNER", + }); + }); + + it("revokes the target's own issued grant", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.memberId], + memberJoins: { [h.ids.memberId]: 1 }, + }), + ); + await seedIssuedGrant(h, "room-1", h.ids.memberId); + await h.lifecycle.kickFromRoom("room-1", h.ids.memberId, h.ids.ownerId); + const { slot } = h.deps.requireIdentity(); + expect(loadIssuedRoomGrant(slot, "room-1", h.ids.memberId)).toBeUndefined(); + expect(h.broadcastRevocation).toHaveBeenCalledTimes(1); + }); + + it("removes the target from both the member and invited lists, and bumps the room's version", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + version: 1, + owner: h.ids.ownerId, + members: [h.ids.memberId], + memberJoins: { [h.ids.memberId]: 1 }, + }), + ); + await h.lifecycle.kickFromRoom("room-1", h.ids.memberId, h.ids.ownerId); + expect(h.recordMemberOp).toHaveBeenCalledWith( + expect.anything(), + "member", + "leave", + h.ids.memberId, + ); + expect(h.recordMemberOp).toHaveBeenCalledWith( + expect.anything(), + "invited", + "leave", + h.ids.memberId, + ); + const updated = h.deps.rooms.get("room-1"); + expect(updated?.version).toBe(2); + expect(updated?.members).not.toContain(h.ids.memberId); + }); + + it("broadcasts the updated room", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.memberId], + memberJoins: { [h.ids.memberId]: 1 }, + }), + ); + await h.lifecycle.kickFromRoom("room-1", h.ids.memberId, h.ids.ownerId); + expect(h.broadcastPatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "room_upsert" }), + ); + }); +}); + +// destroyRoom's per-member `this.deps.agents.set(memberId, member)` call is the same same-object-reference no-op documented at the top of room-lifecycle.test.ts's joinRoom block -- `member` is fetched via `this.deps.agents.get(memberId)` and mutated (its `subscribedRooms` property reassigned) in place before this call, so re-setting the map entry to the identical reference it already holds changes nothing observable. +describe("RoomLifecycle — destroyRoom", () => { + it("throws ROOM_NOT_FOUND for an unknown room", async () => { + const h = await makeHarness(); + await expect( + h.lifecycle.destroyRoom("no-such-room", h.ids.ownerId), + ).rejects.toMatchObject({ + message: "Room no-such-room not found", + code: "ROOM_NOT_FOUND", + }); + }); + + it("throws NOT_OWNER when the requester isn't the room's own owner", async () => { + const h = await makeHarness(); + h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId })); + await expect( + h.lifecycle.destroyRoom("room-1", h.ids.memberId), + ).rejects.toMatchObject({ + message: "Only the room owner can destroy", + code: "NOT_OWNER", + }); + }); + + it("revokes every member's own issued grant", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.ownerId, h.ids.memberId], + }), + ); + await seedIssuedGrant(h, "room-1", h.ids.memberId); + await h.lifecycle.destroyRoom("room-1", h.ids.ownerId); + const { slot } = h.deps.requireIdentity(); + expect(loadIssuedRoomGrant(slot, "room-1", h.ids.memberId)).toBeUndefined(); + }); + + it("updates subscribedRooms and broadcasts agent_upsert only for members with a local agent record", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.ownerId, h.ids.memberId], + }), + ); + h.deps.agents.set( + h.ids.memberId, + agent({ id: h.ids.memberId, subscribedRooms: ["room-1", "other-room"] }), + ); + await h.lifecycle.destroyRoom("room-1", h.ids.ownerId); + expect(h.deps.agents.get(h.ids.memberId)?.subscribedRooms).toEqual([ + "other-room", + ]); + expect(h.broadcastPatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "agent_upsert" }), + ); + }); + + it("skips the agent_upsert broadcast for a member with no local agent record", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ id: "room-1", owner: h.ids.ownerId, members: [h.ids.memberId] }), + ); + await h.lifecycle.destroyRoom("room-1", h.ids.ownerId); + expect(h.broadcastPatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "agent_upsert" }), + ); + }); + + it("deletes the room and its message history, and broadcasts room_delete", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ id: "room-1", owner: h.ids.ownerId, members: [] }), + ); + h.deps.messages.set("room-1", []); + await h.lifecycle.destroyRoom("room-1", h.ids.ownerId); + expect(h.deps.rooms.has("room-1")).toBe(false); + expect(h.deps.messages.has("room-1")).toBe(false); + expect(h.broadcastPatch).toHaveBeenCalledWith({ + type: "room_delete", + roomId: "room-1", + }); + }); +}); +// leaveRoom's own `this.deps.rooms.set(roomId, room)` and `this.deps.agents.set(agentId, agent)` calls are the same same-object-reference no-ops documented at the top of room-lifecycle.test.ts's joinRoom block. +describe("RoomLifecycle — leaveRoom / leaveRemoteRoom", () => { + it("throws ROOM_NOT_FOUND for an unknown room", async () => { + const h = await makeHarness(); + await expect( + h.lifecycle.leaveRoom("no-such-room", h.ids.ownerId), + ).rejects.toMatchObject({ + message: "Room no-such-room not found", + code: "ROOM_NOT_FOUND", + }); + }); + + it("goes remote when this store's own agent leaves a room it does not own, omitting reason entirely (not just as undefined)", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + h.deps.rooms.set(roomPath, room({ id: roomPath, owner: h.ids.memberId })); + const { slot } = h.deps.requireIdentity(); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + saveRoomToken(slot, roomPath, token); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + } satisfies ManageOutcome); + await h.lifecycle.leaveRoom(roomPath, h.ids.ownerId); + expect(h.sendRoomRequest).toHaveBeenCalled(); + expect(h.deps.rooms.has(roomPath)).toBe(false); + const params = h.sendRoomRequest.mock.calls[0]?.[1]?.params as Record< + string, + unknown + >; + expect(params).not.toHaveProperty("reason"); + }); + + it("takes the local path when this store's own agent leaves a room it owns", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.ownerId, h.ids.memberId], + memberJoins: { [h.ids.ownerId]: 1, [h.ids.memberId]: 1 }, + }), + ); + await h.lifecycle.leaveRoom("room-1", h.ids.ownerId); + expect(h.sendRoomRequest).not.toHaveBeenCalled(); + }); + + it("always takes the local path for an agent other than this store's own peer", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.ownerId, h.ids.memberId], + memberJoins: { [h.ids.ownerId]: 1, [h.ids.memberId]: 1 }, + }), + ); + await h.lifecycle.leaveRoom("room-1", h.ids.memberId); + expect(h.sendRoomRequest).not.toHaveBeenCalled(); + expect(h.deps.rooms.get("room-1")?.members).not.toContain(h.ids.memberId); + }); + + it("updates subscribedRooms and broadcasts agent_upsert only for a known agent", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.ownerId, h.ids.memberId], + memberJoins: { [h.ids.ownerId]: 1, [h.ids.memberId]: 1 }, + }), + ); + h.deps.agents.set( + h.ids.memberId, + agent({ + id: h.ids.memberId, + subscribedRooms: ["room-1", "other-room"], + }), + ); + await h.lifecycle.leaveRoom("room-1", h.ids.memberId); + expect(h.deps.agents.get(h.ids.memberId)?.subscribedRooms).toEqual([ + "other-room", + ]); + expect(h.broadcastPatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "agent_upsert" }), + ); + }); + + it("skips the agent_upsert broadcast when the leaving agent has no local record", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.ownerId, h.ids.memberId], + memberJoins: { [h.ids.ownerId]: 1, [h.ids.memberId]: 1 }, + }), + ); + await h.lifecycle.leaveRoom("room-1", h.ids.memberId); + expect(h.broadcastPatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "agent_upsert" }), + ); + }); + + it("notifies federated links only when the room is federated", async () => { + const federated = await makeHarness(); + federated.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: federated.ids.ownerId, + members: [federated.ids.ownerId, federated.ids.memberId], + memberJoins: { + [federated.ids.ownerId]: 1, + [federated.ids.memberId]: 1, + }, + federated: true, + }), + ); + await federated.lifecycle.leaveRoom("room-1", federated.ids.memberId); + expect(federated.broadcastRoomLeave).toHaveBeenCalledWith( + "room-1", + federated.ids.memberId, + ); + + const plain = await makeHarness(); + plain.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: plain.ids.ownerId, + members: [plain.ids.ownerId, plain.ids.memberId], + memberJoins: { [plain.ids.ownerId]: 1, [plain.ids.memberId]: 1 }, + federated: false, + }), + ); + await plain.lifecycle.leaveRoom("room-1", plain.ids.memberId); + expect(plain.broadcastRoomLeave).not.toHaveBeenCalled(); + }); + + it("destroys the room when the last remaining member is also its own owner", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.ownerId], + memberJoins: { [h.ids.ownerId]: 1 }, + }), + ); + await h.lifecycle.leaveRoom("room-1", h.ids.ownerId); + expect(h.deps.rooms.has("room-1")).toBe(false); + expect(h.broadcastPatch).toHaveBeenCalledWith({ + type: "room_delete", + roomId: "room-1", + }); + }); + + it("does not destroy the room when the last remaining member leaving is not its owner", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.memberId], + memberJoins: { [h.ids.memberId]: 1 }, + }), + ); + await h.lifecycle.leaveRoom("room-1", h.ids.memberId); + expect(h.deps.rooms.has("room-1")).toBe(true); + }); + + it("leaveRemoteRoom throws NOT_MEMBER when no token is persisted for the target room", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + h.deps.rooms.set(roomPath, room({ id: roomPath, owner: h.ids.memberId })); + await expect( + h.lifecycle.leaveRoom(roomPath, h.ids.ownerId), + ).rejects.toMatchObject({ + message: `No room:member token for ${roomPath}`, + code: "NOT_MEMBER", + }); + }); + + it("leaveRemoteRoom throws LEAVE_FAILED naming the outcome code on a non-ok outcome", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + h.deps.rooms.set(roomPath, room({ id: roomPath, owner: h.ids.memberId })); + const { slot } = h.deps.requireIdentity(); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + saveRoomToken(slot, roomPath, token); + h.sendRoomRequest.mockResolvedValue({ + result: "error", + code: "not_a_member", + } satisfies ManageOutcome); + await expect( + h.lifecycle.leaveRoom(roomPath, h.ids.ownerId), + ).rejects.toMatchObject({ + message: `Leaving ${roomPath} failed (not_a_member)`, + code: "LEAVE_FAILED", + }); + }); + + it("leaveRemoteRoom deletes the persisted token and every local record on success", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + h.deps.rooms.set(roomPath, room({ id: roomPath, owner: h.ids.memberId })); + h.deps.messages.set(roomPath, []); + const { slot } = h.deps.requireIdentity(); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + saveRoomToken(slot, roomPath, token); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + } satisfies ManageOutcome); + await h.lifecycle.leaveRoom(roomPath, h.ids.ownerId); + expect(loadRoomTokens(slot)[roomPath]).toBeUndefined(); + expect(h.deps.rooms.has(roomPath)).toBe(false); + expect(h.deps.messages.has(roomPath)).toBe(false); + }); +}); diff --git a/src/test/room-lifecycle-remote.test.ts b/src/test/room-lifecycle-remote.test.ts new file mode 100644 index 0000000..740d669 --- /dev/null +++ b/src/test/room-lifecycle-remote.test.ts @@ -0,0 +1,517 @@ +/** + * Direct, DI-based unit tests for RoomLifecycle's own remote-request-sending half -- refreshRoomMembers, requestDmAccess, getRoom, revokeMemberGrant, and declineInvite, moved here from room-lifecycle.test.ts/room-lifecycle-membership.test.ts to stay under this repo's max-lines cap after later gap-closing passes. See room-lifecycle.test.ts for createRoom/listRooms/joinRoom/joinRemoteRoom, and room-lifecycle-membership.test.ts for leaveRoom/leaveRemoteRoom/inviteToRoom/kickFromRoom/destroyRoom -- and either file's own header for the full rationale. Real identities and real minted tokens throughout (not opaque placeholders): RoomLifecycle genuinely mints capability tokens and parses roomJoinOkSchema/roomMembersOkSchema's own structural shape (a real COSE_Sign1 tuple, though never signature-verified by this class), so an arbitrary placeholder string fails the schema outright where room-messaging.test.ts's forwarding-only case could get away with one. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { tmpdir } from "node:os"; +import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id"; +import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; +import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; +import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; +import type { ManageOutcome } from "wire-mesh-core/domain/mesh-session"; +import { generateIdentity } from "../core/identity.js"; +import { + loadOrCreateIdentity, + loadIssuedRoomGrant, + loadRoomTokens, + saveIssuedRoomGrant, + saveRoomToken, +} from "../core/identity-store.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { dmRoomPath, ownerNamedRoomPath } from "../core/room-path.js"; +import { randomId } from "../core/random-id.js"; +import { + RoomLifecycle, + type RoomLifecycleDeps, +} from "../core/room-lifecycle.js"; +import type { AgentIdentity, Room } from "../core/types.js"; + +const TOKEN_TTL_MS = 60_000; + +function room(overrides: Partial = {}): Room { + return { + id: "room-1", + version: 1, + name: "room-name", + type: "public", + owner: "", + createdAt: "2026-01-01T00:00:00.000Z", + description: "a room", + members: [], + invited: [], + memberJoins: {}, + memberLeaves: {}, + invitedJoins: {}, + invitedLeaves: {}, + ...overrides, + }; +} + +function agent(overrides: Partial = {}): AgentIdentity { + return { + id: "", + version: 1, + name: "agent-name", + harness: "pi", + cwd: "/tmp/agent", + pid: 1, + startedAt: "2026-01-01T00:00:00.000Z", + visibility: "visible", + status: "active", + tags: [], + subscribedRooms: [], + ...overrides, + }; +} + +interface Identities { + ownerId: string; + ownerPort: Awaited>; + memberId: string; +} + +async function makeIdentities(): Promise { + const owner = generateIdentity(); + const member = generateIdentity(); + return { + ownerId: bytesToHex(Uint8Array.from(owner.deviceId)), + ownerPort: await toIdentityPort(owner), + memberId: bytesToHex(Uint8Array.from(member.deviceId)), + }; +} + +async function mintRoomToken( + issuerPort: Awaited>, + bearerHex: string, + roomPath: string, +): Promise { + const clock = createSystemClock(); + const verdict = await mintCapabilityToken({ + identity: issuerPort, + clock, + tokenId: randomId(), + bearer: deviceIdFromHex(bearerHex), + capability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + if (!verdict.ok) + throw new Error("expected the fixture token to mint successfully"); + return verdict.token; +} + +interface Harness { + deps: RoomLifecycleDeps; + lifecycle: RoomLifecycle; + ids: Identities; + slotDir: string; + bump: RoomLifecycleDeps["deliveryEngine"]["bump"]; + recordMemberOp: ReturnType; + refreshMembership: ReturnType; + broadcastPatch: ReturnType; + deliverToRoom: ReturnType; + deliverLocallyAndBroadcast: ReturnType; + broadcastRoomJoin: ReturnType; + broadcastRoomLeave: ReturnType; + sendRoomRequest: ReturnType; + broadcastRevocation: ReturnType; +} + +async function makeHarness(): Promise { + const ids = await makeIdentities(); + const slotDir = fs.mkdtempSync(path.join(tmpdir(), "room-lifecycle-test-")); + const slot = { harness: "test", cwd: "room-lifecycle", dir: slotDir }; + // saveRoomToken/saveIssuedRoomGrant write into this slot's own persisted identity file, which must already exist on disk -- unrelated to which crypto identity requireIdentity() uses for minting/verifying, purely a filesystem bookkeeping precondition. + loadOrCreateIdentity(slot); + + const bump = vi.fn((readonlyEntity: Readonly<{ version: number }>) => { + const entity = readonlyEntity as { version: number }; + entity.version += 1; + return entity; + }) as unknown as RoomLifecycleDeps["deliveryEngine"]["bump"]; + const recordMemberOp = vi.fn< + RoomLifecycleDeps["deliveryEngine"]["recordMemberOp"] + >((r, list, op, agentId) => { + const joins = list === "member" ? r.memberJoins : r.invitedJoins; + const leaves = list === "member" ? r.memberLeaves : r.invitedLeaves; + if (op === "join") joins[agentId] = r.version; + else leaves[agentId] = r.version; + }); + const refreshMembership = vi.fn< + RoomLifecycleDeps["deliveryEngine"]["refreshMembership"] + >((r) => { + r.members = Object.keys(r.memberJoins).filter( + (id) => (r.memberJoins[id] ?? 0) > (r.memberLeaves[id] ?? 0), + ); + r.invited = Object.keys(r.invitedJoins).filter( + (id) => (r.invitedJoins[id] ?? 0) > (r.invitedLeaves[id] ?? 0), + ); + }); + const broadcastPatch = vi.fn().mockResolvedValue(undefined); + const deliverToRoom = vi.fn().mockResolvedValue(undefined); + const deliverLocallyAndBroadcast = vi.fn().mockResolvedValue(undefined); + const broadcastRoomJoin = vi.fn().mockResolvedValue(undefined); + const broadcastRoomLeave = vi.fn().mockResolvedValue(undefined); + const broadcastRevocation = vi.fn().mockResolvedValue(undefined); + const sendRoomRequest = vi.fn().mockResolvedValue({ + result: "error", + code: "not_connected", + } satisfies ManageOutcome); + + const deps: RoomLifecycleDeps = { + rooms: new Map(), + messages: new Map(), + agents: new Map(), + dmRequestsInitiatedByMe: new Set(), + getPeerId: () => ids.ownerId, + requireIdentity: () => ({ + identity: ids.ownerPort, + clock: createSystemClock(), + slot, + revocation: createRevocationView(), + }), + requireTransport: () => + ({ sendRoomRequest, broadcastRevocation }) as unknown as ReturnType< + RoomLifecycleDeps["requireTransport"] + >, + deliveryEngine: { + bump, + recordMemberOp, + refreshMembership, + broadcastPatch, + deliverToRoom, + deliverLocallyAndBroadcast, + }, + federation: { broadcastRoomJoin, broadcastRoomLeave }, + }; + + return { + deps, + lifecycle: new RoomLifecycle(deps), + ids, + slotDir, + bump, + recordMemberOp, + refreshMembership, + broadcastPatch, + deliverToRoom, + deliverLocallyAndBroadcast, + broadcastRoomJoin, + broadcastRoomLeave, + sendRoomRequest, + broadcastRevocation, + }; +} + +function roomJoinOkOutcome( + token: CapabilityToken, + members: readonly string[], + extra: Record = {}, +): ManageOutcome { + return { + result: "ok", + "granted-token": token, + members: members.map((hex) => ({ device: deviceIdFromHex(hex) })), + ...extra, + } as unknown as ManageOutcome; +} + +function roomMembersOkOutcome( + members: readonly string[], + extra: Record = {}, +): ManageOutcome { + return { + result: "ok", + members: members.map((hex) => ({ device: deviceIdFromHex(hex) })), + ...extra, + } as unknown as ManageOutcome; +} +describe("RoomLifecycle — refreshRoomMembers", () => { + it("throws ROOM_NOT_FOUND for a non-owner-named path", async () => { + const h = await makeHarness(); + const dmPath = dmRoomPath(h.ids.ownerId, h.ids.memberId); + await expect(h.lifecycle.refreshRoomMembers(dmPath)).rejects.toMatchObject({ + code: "ROOM_NOT_FOUND", + }); + }); + + it("throws NOT_MEMBER when no room:member token is persisted", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + await expect( + h.lifecycle.refreshRoomMembers(roomPath), + ).rejects.toMatchObject({ + message: `No room:member token for ${roomPath}`, + code: "NOT_MEMBER", + }); + }); + + it("throws REFRESH_FAILED naming the outcome code on a non-ok outcome", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + const { slot } = h.deps.requireIdentity(); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + saveRoomToken(slot, roomPath, token); + h.sendRoomRequest.mockResolvedValue({ + result: "error", + code: "not_a_member", + } satisfies ManageOutcome); + await expect( + h.lifecycle.refreshRoomMembers(roomPath), + ).rejects.toMatchObject({ + message: `room.members refresh for ${roomPath} failed (not_a_member)`, + code: "REFRESH_FAILED", + }); + }); + + it("throws MALFORMED_RESPONSE when the outcome fails the roomMembersOk schema", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + const { slot } = h.deps.requireIdentity(); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + saveRoomToken(slot, roomPath, token); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + nonsense: true, + }); + await expect( + h.lifecycle.refreshRoomMembers(roomPath), + ).rejects.toMatchObject({ + message: `room.members response for ${roomPath} was malformed`, + code: "MALFORMED_RESPONSE", + }); + }); + + it("sends the request scoped to the room path, over a room.members verb", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + const { slot } = h.deps.requireIdentity(); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + saveRoomToken(slot, roomPath, token); + h.sendRoomRequest.mockResolvedValue(roomMembersOkOutcome([h.ids.ownerId])); + await h.lifecycle.refreshRoomMembers(roomPath); + expect(h.sendRoomRequest).toHaveBeenCalledWith( + h.ids.memberId, + expect.objectContaining({ + params: expect.objectContaining({ verb: "room.members" }), + }), + { kind: "room", path: roomPath }, + token, + ); + }); + + it("starts the version at 1, with an empty description and no existing local copy", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + const { slot } = h.deps.requireIdentity(); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + saveRoomToken(slot, roomPath, token); + h.sendRoomRequest.mockResolvedValue(roomMembersOkOutcome([h.ids.ownerId])); + const refreshed = await h.lifecycle.refreshRoomMembers(roomPath); + expect(refreshed.version).toBe(1); + expect(refreshed.description).toBe(""); + expect(refreshed.federated).toBe(false); + expect(refreshed.memberJoins).toEqual({ [h.ids.ownerId]: 1 }); + expect(refreshed.invited).toEqual([]); + }); + + it("increments the version relative to the existing local copy, and preserves its federated flag", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + h.deps.rooms.set( + roomPath, + room({ id: roomPath, version: 1, federated: true }), + ); + const { slot } = h.deps.requireIdentity(); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + saveRoomToken(slot, roomPath, token); + h.sendRoomRequest.mockResolvedValue(roomMembersOkOutcome([h.ids.ownerId])); + const refreshed = await h.lifecycle.refreshRoomMembers(roomPath); + expect(refreshed.version).toBe(2); + expect(refreshed.federated).toBe(true); + }); + + it("prefers the room-state extension's own fields over the existing local copy's", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + h.deps.rooms.set( + roomPath, + room({ id: roomPath, name: "old-name", description: "old-desc" }), + ); + const { slot } = h.deps.requireIdentity(); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + saveRoomToken(slot, roomPath, token); + h.sendRoomRequest.mockResolvedValue( + roomMembersOkOutcome([h.ids.ownerId], { + "room-state": { + name: "new-name", + description: "new-desc", + type: "private", + }, + }), + ); + const refreshed = await h.lifecycle.refreshRoomMembers(roomPath); + expect(refreshed.name).toBe("new-name"); + expect(refreshed.description).toBe("new-desc"); + }); +}); + +describe("RoomLifecycle — requestDmAccess", () => { + it("records the dm path as initiated by this store before sending", async () => { + const h = await makeHarness(); + h.sendRoomRequest.mockResolvedValue({ + result: "error", + code: "not_invited", + } satisfies ManageOutcome); + await expect( + h.lifecycle.requestDmAccess(h.ids.memberId), + ).rejects.toBeDefined(); + expect(h.deps.dmRequestsInitiatedByMe).toContain( + dmRoomPath(h.ids.ownerId, h.ids.memberId), + ); + }); + + it("throws JOIN_REFUSED naming the outcome code on a non-ok outcome", async () => { + const h = await makeHarness(); + h.sendRoomRequest.mockResolvedValue({ + result: "error", + code: "not_invited", + } satisfies ManageOutcome); + await expect( + h.lifecycle.requestDmAccess(h.ids.memberId), + ).rejects.toMatchObject({ + message: `DM access request to ${h.ids.memberId} was refused (not_invited)`, + code: "JOIN_REFUSED", + }); + }); + + it("throws MALFORMED_RESPONSE when the outcome fails the roomJoinOk schema", async () => { + const h = await makeHarness(); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + nonsense: true, + }); + await expect( + h.lifecycle.requestDmAccess(h.ids.memberId), + ).rejects.toMatchObject({ + message: `DM access response from ${h.ids.memberId} was malformed`, + code: "MALFORMED_RESPONSE", + }); + }); + + it("persists the granted token under the dm path on success", async () => { + const h = await makeHarness(); + const dmPath = dmRoomPath(h.ids.ownerId, h.ids.memberId); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, dmPath); + h.sendRoomRequest.mockResolvedValue(roomJoinOkOutcome(token, [])); + await h.lifecycle.requestDmAccess(h.ids.memberId); + const { slot } = h.deps.requireIdentity(); + expect(loadRoomTokens(slot)[dmPath]).toBeDefined(); + }); +}); + +describe("RoomLifecycle — getRoom", () => { + it("returns the stored room", async () => { + const h = await makeHarness(); + const r = room({ id: "room-1" }); + h.deps.rooms.set("room-1", r); + await expect(h.lifecycle.getRoom("room-1")).resolves.toBe(r); + }); + + it("returns undefined for an unknown id", async () => { + const h = await makeHarness(); + await expect(h.lifecycle.getRoom("no-such-room")).resolves.toBeUndefined(); + }); +}); + +/** Persists an issued-grant record for memberId in roomPath as if this store's own identity had genuinely admitted them (invite or join), via the same real crypto path inviteToRoom itself uses -- revokeMemberGrant/kickFromRoom/destroyRoom all read this record by its own token-id, not anything derivable from the token or the in-memory Room object afterward. */ +async function seedIssuedGrant( + h: Harness, + roomPath: string, + memberId: string, +): Promise { + const { slot } = h.deps.requireIdentity(); + const tokenId = randomId(); + const clock = createSystemClock(); + const verdict = await mintCapabilityToken({ + identity: h.ids.ownerPort, + clock, + tokenId, + bearer: deviceIdFromHex(memberId), + capability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + if (!verdict.ok) + throw new Error("expected the fixture token to mint successfully"); + saveIssuedRoomGrant(slot, roomPath, memberId, tokenId); +} + +describe("RoomLifecycle — revokeMemberGrant", () => { + it("does nothing when no issued-grant record exists for the member", async () => { + const h = await makeHarness(); + await expect( + h.lifecycle.revokeMemberGrant("room-1", h.ids.memberId), + ).resolves.toBeUndefined(); + expect(h.broadcastRevocation).not.toHaveBeenCalled(); + }); + + it("mints and broadcasts a revocation entry, then forgets the issued-grant record", async () => { + const h = await makeHarness(); + await seedIssuedGrant(h, "room-1", h.ids.memberId); + await h.lifecycle.revokeMemberGrant("room-1", h.ids.memberId); + expect(h.broadcastRevocation).toHaveBeenCalledTimes(1); + const { slot } = h.deps.requireIdentity(); + expect(loadIssuedRoomGrant(slot, "room-1", h.ids.memberId)).toBeUndefined(); + }); +}); + +describe("RoomLifecycle — declineInvite", () => { + it("throws NOT_SELF when declining on behalf of another agent", async () => { + const h = await makeHarness(); + await expect( + h.lifecycle.declineInvite("room-1", h.ids.memberId, "no thanks"), + ).rejects.toMatchObject({ + message: `Cannot decline an invite on behalf of ${h.ids.memberId}`, + code: "NOT_SELF", + }); + }); + + it("throws ROOM_NOT_FOUND for a non-owner-named path (e.g. a DM path)", async () => { + const h = await makeHarness(); + const dmPath = dmRoomPath(h.ids.ownerId, h.ids.memberId); + await expect( + h.lifecycle.declineInvite(dmPath, h.ids.ownerId, "no thanks"), + ).rejects.toMatchObject({ + message: `Room ${dmPath} not found`, + code: "ROOM_NOT_FOUND", + }); + }); + + it("delegates to a real room.leave request against the room's own owner, carrying the decline reason", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + const { slot } = h.deps.requireIdentity(); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + saveRoomToken(slot, roomPath, token); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + } satisfies ManageOutcome); + await h.lifecycle.declineInvite(roomPath, h.ids.ownerId, "no thanks"); + expect(h.sendRoomRequest).toHaveBeenCalledWith( + h.ids.memberId, + expect.objectContaining({ + params: expect.objectContaining({ + verb: "room.leave", + reason: "no thanks", + }), + }), + { kind: "room", path: roomPath }, + token, + ); + }); +}); diff --git a/src/test/room-lifecycle.test.ts b/src/test/room-lifecycle.test.ts new file mode 100644 index 0000000..c375158 --- /dev/null +++ b/src/test/room-lifecycle.test.ts @@ -0,0 +1,714 @@ +/** + * Direct, DI-based unit tests for RoomLifecycle -- room CRUD and the requester's own outbound half of the wire protocol, split across three files to stay under this repo's max-lines cap: this file covers createRoom, listRooms, and joinRoom/joinRemoteRoom. See room-lifecycle-remote.test.ts for refreshRoomMembers/requestDmAccess and room-lifecycle-membership.test.ts for getRoom/leaveRoom/leaveRemoteRoom/inviteToRoom/declineInvite/revokeMemberGrant/kickFromRoom/destroyRoom (both moved out to rebalance line counts after later gap-closing passes). Real identities and real minted tokens throughout (not opaque placeholders): RoomLifecycle genuinely mints capability tokens and parses roomJoinOkSchema/roomMembersOkSchema's own structural shape (a real COSE_Sign1 tuple, though never signature-verified by this class), so an arbitrary placeholder string fails the schema outright where room-messaging.test.ts's forwarding-only case could get away with one. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { tmpdir } from "node:os"; +import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id"; +import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; +import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; +import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; +import type { ManageOutcome } from "wire-mesh-core/domain/mesh-session"; +import { generateIdentity } from "../core/identity.js"; +import { + loadOrCreateIdentity, + loadRoomTokens, + saveRoomToken, +} from "../core/identity-store.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { dmRoomPath, ownerNamedRoomPath } from "../core/room-path.js"; +import { randomId } from "../core/random-id.js"; +import { + RoomLifecycle, + type RoomLifecycleDeps, +} from "../core/room-lifecycle.js"; +import type { AgentIdentity, Room } from "../core/types.js"; + +const TOKEN_TTL_MS = 60_000; + +function room(overrides: Partial = {}): Room { + return { + id: "room-1", + version: 1, + name: "room-name", + type: "public", + owner: "", + createdAt: "2026-01-01T00:00:00.000Z", + description: "a room", + members: [], + invited: [], + memberJoins: {}, + memberLeaves: {}, + invitedJoins: {}, + invitedLeaves: {}, + ...overrides, + }; +} + +function agent(overrides: Partial = {}): AgentIdentity { + return { + id: "", + version: 1, + name: "agent-name", + harness: "pi", + cwd: "/tmp/agent", + pid: 1, + startedAt: "2026-01-01T00:00:00.000Z", + visibility: "visible", + status: "active", + tags: [], + subscribedRooms: [], + ...overrides, + }; +} + +interface Identities { + ownerId: string; + ownerPort: Awaited>; + memberId: string; +} + +async function makeIdentities(): Promise { + const owner = generateIdentity(); + const member = generateIdentity(); + return { + ownerId: bytesToHex(Uint8Array.from(owner.deviceId)), + ownerPort: await toIdentityPort(owner), + memberId: bytesToHex(Uint8Array.from(member.deviceId)), + }; +} + +async function mintRoomToken( + issuerPort: Awaited>, + bearerHex: string, + roomPath: string, +): Promise { + const clock = createSystemClock(); + const verdict = await mintCapabilityToken({ + identity: issuerPort, + clock, + tokenId: randomId(), + bearer: deviceIdFromHex(bearerHex), + capability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + if (!verdict.ok) + throw new Error("expected the fixture token to mint successfully"); + return verdict.token; +} + +interface Harness { + deps: RoomLifecycleDeps; + lifecycle: RoomLifecycle; + ids: Identities; + slotDir: string; + bump: RoomLifecycleDeps["deliveryEngine"]["bump"]; + recordMemberOp: ReturnType; + refreshMembership: ReturnType; + broadcastPatch: ReturnType; + deliverToRoom: ReturnType; + deliverLocallyAndBroadcast: ReturnType; + broadcastRoomJoin: ReturnType; + broadcastRoomLeave: ReturnType; + sendRoomRequest: ReturnType; + broadcastRevocation: ReturnType; +} + +async function makeHarness(): Promise { + const ids = await makeIdentities(); + const slotDir = fs.mkdtempSync(path.join(tmpdir(), "room-lifecycle-test-")); + const slot = { harness: "test", cwd: "room-lifecycle", dir: slotDir }; + // saveRoomToken/saveIssuedRoomGrant write into this slot's own persisted identity file, which must already exist on disk -- unrelated to which crypto identity requireIdentity() uses for minting/verifying, purely a filesystem bookkeeping precondition. + loadOrCreateIdentity(slot); + + const bump = vi.fn((readonlyEntity: Readonly<{ version: number }>) => { + const entity = readonlyEntity as { version: number }; + entity.version += 1; + return entity; + }) as unknown as RoomLifecycleDeps["deliveryEngine"]["bump"]; + const recordMemberOp = vi.fn< + RoomLifecycleDeps["deliveryEngine"]["recordMemberOp"] + >((r, list, op, agentId) => { + const joins = list === "member" ? r.memberJoins : r.invitedJoins; + const leaves = list === "member" ? r.memberLeaves : r.invitedLeaves; + if (op === "join") joins[agentId] = r.version; + else leaves[agentId] = r.version; + }); + const refreshMembership = vi.fn< + RoomLifecycleDeps["deliveryEngine"]["refreshMembership"] + >((r) => { + r.members = Object.keys(r.memberJoins).filter( + (id) => (r.memberJoins[id] ?? 0) > (r.memberLeaves[id] ?? 0), + ); + r.invited = Object.keys(r.invitedJoins).filter( + (id) => (r.invitedJoins[id] ?? 0) > (r.invitedLeaves[id] ?? 0), + ); + }); + const broadcastPatch = vi.fn().mockResolvedValue(undefined); + const deliverToRoom = vi.fn().mockResolvedValue(undefined); + const deliverLocallyAndBroadcast = vi.fn().mockResolvedValue(undefined); + const broadcastRoomJoin = vi.fn().mockResolvedValue(undefined); + const broadcastRoomLeave = vi.fn().mockResolvedValue(undefined); + const broadcastRevocation = vi.fn().mockResolvedValue(undefined); + const sendRoomRequest = vi.fn().mockResolvedValue({ + result: "error", + code: "not_connected", + } satisfies ManageOutcome); + + const deps: RoomLifecycleDeps = { + rooms: new Map(), + messages: new Map(), + agents: new Map(), + dmRequestsInitiatedByMe: new Set(), + getPeerId: () => ids.ownerId, + requireIdentity: () => ({ + identity: ids.ownerPort, + clock: createSystemClock(), + slot, + revocation: createRevocationView(), + }), + requireTransport: () => + ({ sendRoomRequest, broadcastRevocation }) as unknown as ReturnType< + RoomLifecycleDeps["requireTransport"] + >, + deliveryEngine: { + bump, + recordMemberOp, + refreshMembership, + broadcastPatch, + deliverToRoom, + deliverLocallyAndBroadcast, + }, + federation: { broadcastRoomJoin, broadcastRoomLeave }, + }; + + return { + deps, + lifecycle: new RoomLifecycle(deps), + ids, + slotDir, + bump, + recordMemberOp, + refreshMembership, + broadcastPatch, + deliverToRoom, + deliverLocallyAndBroadcast, + broadcastRoomJoin, + broadcastRoomLeave, + sendRoomRequest, + broadcastRevocation, + }; +} + +function roomJoinOkOutcome( + token: CapabilityToken, + members: readonly string[], + extra: Record = {}, +): ManageOutcome { + return { + result: "ok", + "granted-token": token, + members: members.map((hex) => ({ device: deviceIdFromHex(hex) })), + ...extra, + } as unknown as ManageOutcome; +} + +function roomMembersOkOutcome( + members: readonly string[], + extra: Record = {}, +): ManageOutcome { + return { + result: "ok", + members: members.map((hex) => ({ device: deviceIdFromHex(hex) })), + ...extra, + } as unknown as ManageOutcome; +} + +// mintOwnerRootGrant's `if (!verdict.ok)` failure branch (and the two StringLiteral mutants Stryker raises inside its error message) is genuinely unreachable with the real crypto this test suite uses throughout: mintCapabilityToken only fails its own internal delegation-narrowing checks, and a parent-less, delegationsRemaining:0 root mint against a validly-generated identity has no narrowing to fail. Forcing a failure here would need either a fake identity port (defeating the whole point of using real crypto to catch real signature/schema bugs elsewhere in this file) or reaching into mintCapabilityToken's own internals -- left undocumented-but-untested rather than chased with a contrived fixture, matching this session's own diminishing-returns precedent for similarly unreachable mint-failure branches. +describe("RoomLifecycle — createRoom", () => { + it("mints and persists the owner's own root grant", async () => { + const h = await makeHarness(); + const created = await h.lifecycle.createRoom({ + name: "My Room", + type: "public", + owner: h.ids.ownerId, + description: "a room", + }); + const { slot } = h.deps.requireIdentity(); + expect(loadRoomTokens(slot)[created.id]).toBeDefined(); + }); + + it("slugs the given name into the room-path grammar", async () => { + const h = await makeHarness(); + const created = await h.lifecycle.createRoom({ + name: "My Cool Room!", + type: "public", + owner: h.ids.ownerId, + description: "", + }); + expect(created.name).toBe("My-Cool-Room"); + }); + + it("prefixes the local name with an underscore for a secret room", async () => { + const h = await makeHarness(); + const created = await h.lifecycle.createRoom({ + name: "hidden", + type: "secret", + owner: h.ids.ownerId, + description: "", + }); + expect(created.id).toBe(ownerNamedRoomPath(h.ids.ownerId, "_hidden")); + }); + + it("throws ROOM_EXISTS naming the exact id when the room already exists", async () => { + const h = await makeHarness(); + await h.lifecycle.createRoom({ + name: "dup", + type: "public", + owner: h.ids.ownerId, + description: "", + }); + const id = ownerNamedRoomPath(h.ids.ownerId, "dup"); + await expect( + h.lifecycle.createRoom({ + name: "dup", + type: "public", + owner: h.ids.ownerId, + description: "", + }), + ).rejects.toMatchObject({ + message: `Room ${id} already exists`, + code: "ROOM_EXISTS", + }); + }); + + it("defaults federated to false when omitted", async () => { + const h = await makeHarness(); + const created = await h.lifecycle.createRoom({ + name: "r", + type: "public", + owner: h.ids.ownerId, + description: "", + }); + expect(created.federated).toBe(false); + }); + + it("honours an explicit federated: true", async () => { + const h = await makeHarness(); + const created = await h.lifecycle.createRoom({ + name: "r", + type: "public", + owner: h.ids.ownerId, + description: "", + federated: true, + }); + expect(created.federated).toBe(true); + }); + + it("seeds an empty message history and broadcasts a room_upsert", async () => { + const h = await makeHarness(); + const created = await h.lifecycle.createRoom({ + name: "r", + type: "public", + owner: h.ids.ownerId, + description: "", + }); + expect(h.deps.messages.get(created.id)).toEqual([]); + expect(h.broadcastPatch).toHaveBeenCalledWith({ + type: "room_upsert", + room: created, + }); + }); + + it("seeds members/memberJoins with exactly the owner", async () => { + const h = await makeHarness(); + const created = await h.lifecycle.createRoom({ + name: "r", + type: "public", + owner: h.ids.ownerId, + description: "", + }); + expect(created.members).toEqual([h.ids.ownerId]); + expect(created.memberJoins).toEqual({ [h.ids.ownerId]: 1 }); + }); +}); + +describe("RoomLifecycle — listRooms", () => { + it("includes public and private rooms regardless of membership", async () => { + const h = await makeHarness(); + h.deps.rooms.set("pub", room({ id: "pub", type: "public", members: [] })); + h.deps.rooms.set( + "priv", + room({ id: "priv", type: "private", members: [] }), + ); + const result = await h.lifecycle.listRooms(h.ids.memberId); + expect(result.map((r) => r.id).sort()).toEqual(["priv", "pub"]); + }); + + it("excludes a secret room the requester is not a member of", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "sec", + room({ id: "sec", type: "secret", members: [h.ids.ownerId] }), + ); + const result = await h.lifecycle.listRooms(h.ids.memberId); + expect(result).toEqual([]); + }); + + it("includes a secret room the requester is a member of", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "sec", + room({ id: "sec", type: "secret", members: [h.ids.memberId] }), + ); + const result = await h.lifecycle.listRooms(h.ids.memberId); + expect(result.map((r) => r.id)).toEqual(["sec"]); + }); +}); + +// joinRoom's own `this.deps.rooms.set(roomId, room)` and `this.deps.agents.set(agentId, agent)` calls each have one provable equivalent mutant Stryker still raises: removing them. Both `room` and `agent` are the same object references already fetched via `.get()`, and every mutation up to each call (bump/recordMemberOp/refreshMembership for room; push/bump for agent) already happened in place on that reference -- so re-setting the map entry to the identical reference it already holds changes nothing observable, the same Map.set-same-reference pattern documented throughout this repo's own mutation-testing work (agent-registry.ts's setAgentOffline, delivery-engine.ts's applyPatch(agent_offline)). +describe("RoomLifecycle — joinRoom / joinRemoteRoom", () => { + it("goes remote when this store's own agent has no local token, even if a local room record exists", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "shared"); + h.deps.rooms.set(roomPath, room({ id: roomPath, owner: h.ids.memberId })); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + h.sendRoomRequest.mockResolvedValue( + roomJoinOkOutcome(token, [h.ids.ownerId]), + ); + await h.lifecycle.joinRoom(roomPath, h.ids.ownerId); + expect(h.sendRoomRequest).toHaveBeenCalled(); + }); + + it("skips the remote round trip when a local token already exists and the room is known", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "shared"); + h.deps.rooms.set( + roomPath, + room({ + id: roomPath, + owner: h.ids.memberId, + members: [h.ids.memberId], + memberJoins: { [h.ids.memberId]: 1 }, + }), + ); + const { slot } = h.deps.requireIdentity(); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + saveRoomToken(slot, roomPath, token); + await h.lifecycle.joinRoom(roomPath, h.ids.ownerId); + expect(h.sendRoomRequest).not.toHaveBeenCalled(); + }); + + it("always takes the local CRDT path for an agent other than this store's own peer, regardless of tokens", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + owner: h.ids.ownerId, + members: [h.ids.ownerId], + memberJoins: { [h.ids.ownerId]: 1 }, + }), + ); + await h.lifecycle.joinRoom("room-1", h.ids.memberId); + expect(h.sendRoomRequest).not.toHaveBeenCalled(); + expect(h.deps.rooms.get("room-1")?.members).toContain(h.ids.memberId); + }); + + it("goes remote when the room isn't known locally at all", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "unseen"); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + h.sendRoomRequest.mockResolvedValue( + roomJoinOkOutcome(token, [h.ids.ownerId]), + ); + await h.lifecycle.joinRoom(roomPath, h.ids.ownerId); + expect(h.sendRoomRequest).toHaveBeenCalled(); + }); + + it("joinRemoteRoom throws ROOM_NOT_FOUND when joining on behalf of a different agent", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "unseen"); + await expect( + h.lifecycle.joinRoom(roomPath, h.ids.memberId), + ).rejects.toMatchObject({ + message: `Room ${roomPath} not found`, + code: "ROOM_NOT_FOUND", + }); + // (h.ids.memberId isn't this store's own peer, so joinRemoteRoom's own agentId-mismatch guard fires.) + }); + + it("joinRemoteRoom throws ROOM_NOT_FOUND for a non-owner-named path (e.g. a DM path)", async () => { + const h = await makeHarness(); + const dmPath = dmRoomPath(h.ids.ownerId, h.ids.memberId); + await expect( + h.lifecycle.joinRoom(dmPath, h.ids.ownerId), + ).rejects.toMatchObject({ + message: `Room ${dmPath} not found`, + code: "ROOM_NOT_FOUND", + }); + }); + + it("joinRemoteRoom throws JOIN_REFUSED naming the outcome code on a non-ok outcome", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "unseen"); + h.sendRoomRequest.mockResolvedValue({ + result: "error", + code: "not_invited", + } satisfies ManageOutcome); + await expect( + h.lifecycle.joinRoom(roomPath, h.ids.ownerId), + ).rejects.toMatchObject({ + message: `Join request for ${roomPath} was refused (not_invited)`, + code: "JOIN_REFUSED", + }); + }); + + it("joinRemoteRoom throws MALFORMED_RESPONSE when the outcome fails the roomJoinOk schema", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "unseen"); + h.sendRoomRequest.mockResolvedValue({ + result: "ok", + nonsense: true, + }); + await expect( + h.lifecycle.joinRoom(roomPath, h.ids.ownerId), + ).rejects.toMatchObject({ + message: `Join response for ${roomPath} was malformed`, + code: "MALFORMED_RESPONSE", + }); + }); + + it("joinRemoteRoom persists the granted token and builds the room from the response, including its memberJoins", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "unseen"); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + h.sendRoomRequest.mockResolvedValue( + roomJoinOkOutcome(token, [h.ids.ownerId, h.ids.memberId], { + "room-state": { + name: "real-name", + description: "real-desc", + type: "private", + }, + }), + ); + await h.lifecycle.joinRoom(roomPath, h.ids.ownerId); + const stored = h.deps.rooms.get(roomPath); + expect(stored?.name).toBe("real-name"); + expect(stored?.type).toBe("private"); + expect(stored?.members.sort()).toEqual( + [h.ids.ownerId, h.ids.memberId].sort(), + ); + expect(stored?.memberJoins).toEqual({ + [h.ids.ownerId]: 1, + [h.ids.memberId]: 1, + }); + expect(stored?.federated).toBe(false); + const { slot } = h.deps.requireIdentity(); + expect(loadRoomTokens(slot)[roomPath]).toBeDefined(); + }); + + it("joinRemoteRoom falls back to public/localName/empty-description when no room-state extension is present", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "unseen"); + const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath); + h.sendRoomRequest.mockResolvedValue( + roomJoinOkOutcome(token, [h.ids.ownerId]), + ); + await h.lifecycle.joinRoom(roomPath, h.ids.ownerId); + const stored = h.deps.rooms.get(roomPath); + expect(stored?.type).toBe("public"); + expect(stored?.name).toBe("unseen"); + expect(stored?.description).toBe(""); + }); + + it("throws NOT_INVITED for a non-public room the joiner isn't invited to and doesn't own", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + type: "private", + owner: h.ids.ownerId, + members: [], + }), + ); + await expect( + h.lifecycle.joinRoom("room-1", h.ids.memberId), + ).rejects.toMatchObject({ + message: "Not invited to room room-1", + code: "NOT_INVITED", + }); + }); + + it("allows joining a private room when invited", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + type: "private", + owner: h.ids.ownerId, + members: [], + invited: [h.ids.memberId], + invitedJoins: { [h.ids.memberId]: 1 }, + }), + ); + await expect( + h.lifecycle.joinRoom("room-1", h.ids.memberId), + ).resolves.toBeDefined(); + }); + + it("allows the room's own owner to join a private room without being separately invited", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + type: "private", + owner: h.ids.memberId, + members: [], + }), + ); + await expect( + h.lifecycle.joinRoom("room-1", h.ids.memberId), + ).resolves.toBeDefined(); + }); + + it("retires the invited entry when consuming an invitation", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + type: "private", + owner: h.ids.ownerId, + members: [], + invited: [h.ids.memberId], + invitedJoins: { [h.ids.memberId]: 1 }, + }), + ); + await h.lifecycle.joinRoom("room-1", h.ids.memberId); + expect(h.deps.rooms.get("room-1")?.invited).not.toContain(h.ids.memberId); + }); + + it("does not touch the invited list for a fresh join to an already-public room", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ id: "room-1", type: "public", owner: h.ids.ownerId, members: [] }), + ); + await h.lifecycle.joinRoom("room-1", h.ids.memberId); + expect(h.recordMemberOp).not.toHaveBeenCalledWith( + expect.anything(), + "invited", + "leave", + h.ids.memberId, + ); + }); + + it("updates subscribedRooms, bumps the agent's version, and broadcasts agent_upsert only for a known agent not already subscribed", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ id: "room-1", type: "public", owner: h.ids.ownerId, members: [] }), + ); + h.deps.agents.set( + h.ids.memberId, + agent({ id: h.ids.memberId, subscribedRooms: [], version: 1 }), + ); + await h.lifecycle.joinRoom("room-1", h.ids.memberId); + const updated = h.deps.agents.get(h.ids.memberId); + expect(updated?.subscribedRooms).toContain("room-1"); + expect(updated?.version).toBe(2); + expect(h.broadcastPatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "agent_upsert" }), + ); + }); + + it("skips the agent_upsert broadcast when the joining agent has no local record", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ id: "room-1", type: "public", owner: h.ids.ownerId, members: [] }), + ); + await h.lifecycle.joinRoom("room-1", h.ids.memberId); + expect(h.broadcastPatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "agent_upsert" }), + ); + }); + + it("delivers a room_members list built only from agents actually present locally", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ + id: "room-1", + type: "public", + owner: h.ids.ownerId, + members: [h.ids.ownerId], + memberJoins: { [h.ids.ownerId]: 1 }, + }), + ); + h.deps.agents.set( + h.ids.ownerId, + agent({ id: h.ids.ownerId, name: "owner" }), + ); + await h.lifecycle.joinRoom("room-1", h.ids.memberId); + expect(h.deliverLocallyAndBroadcast).toHaveBeenCalledWith( + h.ids.memberId, + expect.objectContaining({ + type: "room_members", + members: [expect.objectContaining({ id: h.ids.ownerId })], + }), + ); + }); + + it("notifies existing room members of the join, excluding the joiner itself", async () => { + const h = await makeHarness(); + h.deps.rooms.set( + "room-1", + room({ id: "room-1", type: "public", owner: h.ids.ownerId, members: [] }), + ); + await h.lifecycle.joinRoom("room-1", h.ids.memberId); + expect(h.deliverToRoom).toHaveBeenCalledWith( + "room-1", + expect.objectContaining({ type: "member_joined", agent: h.ids.memberId }), + h.ids.memberId, + ); + }); + + it("notifies federated links only when the room is federated", async () => { + const federated = await makeHarness(); + federated.deps.rooms.set( + "room-1", + room({ + id: "room-1", + type: "public", + owner: federated.ids.ownerId, + members: [], + federated: true, + }), + ); + await federated.lifecycle.joinRoom("room-1", federated.ids.memberId); + expect(federated.broadcastRoomJoin).toHaveBeenCalledTimes(1); + + const plain = await makeHarness(); + plain.deps.rooms.set( + "room-1", + room({ + id: "room-1", + type: "public", + owner: plain.ids.ownerId, + members: [], + federated: false, + }), + ); + await plain.lifecycle.joinRoom("room-1", plain.ids.memberId); + expect(plain.broadcastRoomJoin).not.toHaveBeenCalled(); + }); +});