From 30b3208671a21ab91b6d1f00008ec942d1881738 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 19:59:54 +0100 Subject: [PATCH 1/3] test(core): assert RoomLifecycle's CRUD, join/leave, and grant logic directly RoomLifecycle was previously exercised only indirectly through end-to-end integration tests. This adds direct, DI-based unit tests covering every public method: createRoom (slugging, secret-room naming, duplicate detection, root-grant minting), getRoom, listRooms (secret-room visibility), joinRoom/joinRemoteRoom (local-vs-remote gating, invitation consumption, subscribedRooms/room_members/federation notification), leaveRoom/leaveRemoteRoom (local-vs-remote gating, cascading destroy of an empty owned room), refreshRoomMembers, requestDmAccess, inviteToRoom/declineInvite, revokeMemberGrant, kickFromRoom, and destroyRoom. Uses real generated identities and real minted capability tokens throughout, not opaque placeholders: this class genuinely mints tokens and parses roomJoinOkSchema/roomMembersOkSchema's own structural COSE shape, which an arbitrary placeholder string fails outright. Split across two files to stay under the repo's max-lines cap. --- src/test/room-lifecycle-membership.test.ts | 851 +++++++++++++++++++++ src/test/room-lifecycle.test.ts | 850 ++++++++++++++++++++ 2 files changed, 1701 insertions(+) create mode 100644 src/test/room-lifecycle-membership.test.ts create mode 100644 src/test/room-lifecycle.test.ts diff --git a/src/test/room-lifecycle-membership.test.ts b/src/test/room-lifecycle-membership.test.ts new file mode 100644 index 0000000..05a91e1 --- /dev/null +++ b/src/test/room-lifecycle-membership.test.ts @@ -0,0 +1,851 @@ +/** + * Direct, DI-based unit tests for RoomLifecycle's membership-grant half -- getRoom, leaveRoom/leaveRemoteRoom, inviteToRoom, declineInvite, revokeMemberGrant, kickFromRoom, and destroyRoom. Split from room-lifecycle.test.ts to stay under this repo's max-lines cap: that file covers createRoom/listRooms/joinRoom/refreshRoomMembers/requestDmAccess (getRoom and leaveRoom/leaveRemoteRoom moved here to rebalance line counts after a later gap-closing pass). Both 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); +} + +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(); + }); +}); + +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", 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); + expect(h.recordMemberOp).toHaveBeenCalledWith( + expect.anything(), + "invited", + "join", + h.ids.memberId, + ); + }); + + 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", 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(); + }); +}); + +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({ 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, + ); + }); +}); + +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 — 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", 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.recordMemberOp).toHaveBeenCalledWith( + expect.anything(), + "member", + "leave", + h.ids.memberId, + ); + expect(h.recordMemberOp).toHaveBeenCalledWith( + expect.anything(), + "invited", + "leave", + 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" }), + ); + }); +}); + +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", + }); + }); +}); +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", 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); + }); + + 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"] }), + ); + await h.lifecycle.leaveRoom("room-1", h.ids.memberId); + expect(h.deps.agents.get(h.ids.memberId)?.subscribedRooms).not.toContain( + "room-1", + ); + 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 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 }, + federated: true, + }), + ); + await h.lifecycle.leaveRoom("room-1", h.ids.memberId); + expect(h.broadcastRoomLeave).toHaveBeenCalledWith("room-1", h.ids.memberId); + }); + + 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.test.ts b/src/test/room-lifecycle.test.ts new file mode 100644 index 0000000..b5464ac --- /dev/null +++ b/src/test/room-lifecycle.test.ts @@ -0,0 +1,850 @@ +/** + * Direct, DI-based unit tests for RoomLifecycle -- room CRUD and the requester's own outbound half of the wire protocol, split across two files to stay under this repo's max-lines cap: this file covers createRoom, listRooms, joinRoom/joinRemoteRoom, refreshRoomMembers, and requestDmAccess. See room-lifecycle-membership.test.ts for getRoom and leaveRoom/leaveRemoteRoom (both moved there to rebalance line counts after later gap-closing passes), inviteToRoom, declineInvite, revokeMemberGrant, kickFromRoom, and destroyRoom, and its own copy of this 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, + 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; +} + +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"]); + }); +}); + +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({ 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({ 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({ code: "MALFORMED_RESPONSE" }); + }); + + it("joinRemoteRoom persists the granted token and builds the room from the response", 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(), + ); + 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 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: [] }), + ); + await h.lifecycle.joinRoom("room-1", h.ids.memberId); + expect(h.deps.agents.get(h.ids.memberId)?.subscribedRooms).toContain( + "room-1", + ); + 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(); + }); +}); + +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({ code: "MALFORMED_RESPONSE" }); + }); + + it("starts the version at 1 with 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); + }); + + it("increments the version relative to the existing local copy", async () => { + const h = await makeHarness(); + const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); + h.deps.rooms.set(roomPath, room({ id: roomPath, version: 1 })); + 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); + }); + + 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({ 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(); + }); +}); From 237f6f6f18fe54de00080d03dbf726ea1d2a426d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 00:14:13 +0100 Subject: [PATCH 2/3] test(core): close sixteen real gaps in RoomLifecycle mutation coverage A full mutation run against the current test suite surfaced 26 survivors against a 65.20% baseline; this closes the real gaps among them (mutation score up to 89.76% before this commit, further improved by these fixes): - Every thrown CommsError's own message, not just its code, is now asserted where a test previously checked only the code -- several StringLiteral mutants inside error-message template strings otherwise survive untouched since no assertion ever reads the message text. - joinRemoteRoom and refreshRoomMembers now assert the built room's own memberJoins and federated fields, not just members/name/type -- both were constructed but never independently verified. - refreshRoomMembers now asserts the exact scope passed to sendRoomRequest, and that an existing room's federated flag survives a refresh while a fresh one defaults to false. - joinRoom's own agent-subscription branch now asserts the bumped version, not just the mutated array. - leaveRoom's subscribedRooms filter is now tested against an agent subscribed to more than one room, since a single-entry array can't distinguish "filter out this room" from "filter out everything". - leaveRoom's and kickFromRoom's federated-notification branches are now tested on both sides (federated and not), not just the positive case. - leaveRemoteRoom's reason-omission (undefined, not merely absent-valued) is now asserted via leaveRoom's own remote-leave path, which never passes one. - kickFromRoom now asserts the room's version bump and that the target is actually gone from the recomputed members list, not just that the underlying recordMemberOp calls were made. The remaining survivors are documented in place rather than chased: four are the same Map/rooms.set-on-an-already-stored-reference no-op this repo's own mutation-testing work has already documented elsewhere (agent-registry.ts, delivery-engine.ts), and three are error branches genuinely unreachable against this suite's real, validly-generated crypto identities. Split across three files (from two) to stay under the repo's max-lines cap after these additions. --- src/test/room-lifecycle-membership.test.ts | 101 ++--- src/test/room-lifecycle-remote.test.ts | 470 +++++++++++++++++++++ src/test/room-lifecycle.test.ts | 188 ++------- 3 files changed, 549 insertions(+), 210 deletions(-) create mode 100644 src/test/room-lifecycle-remote.test.ts diff --git a/src/test/room-lifecycle-membership.test.ts b/src/test/room-lifecycle-membership.test.ts index 05a91e1..0d816d0 100644 --- a/src/test/room-lifecycle-membership.test.ts +++ b/src/test/room-lifecycle-membership.test.ts @@ -1,5 +1,5 @@ /** - * Direct, DI-based unit tests for RoomLifecycle's membership-grant half -- getRoom, leaveRoom/leaveRemoteRoom, inviteToRoom, declineInvite, revokeMemberGrant, kickFromRoom, and destroyRoom. Split from room-lifecycle.test.ts to stay under this repo's max-lines cap: that file covers createRoom/listRooms/joinRoom/refreshRoomMembers/requestDmAccess (getRoom and leaveRoom/leaveRemoteRoom moved here to rebalance line counts after a later gap-closing pass). Both 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). + * Direct, DI-based unit tests for RoomLifecycle's membership-grant half -- leaveRoom/leaveRemoteRoom, inviteToRoom, declineInvite, revokeMemberGrant, kickFromRoom, and destroyRoom. Split from room-lifecycle.test.ts (createRoom/listRooms/joinRoom) and room-lifecycle-remote.test.ts (refreshRoomMembers/requestDmAccess/getRoom) 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"; @@ -232,20 +232,7 @@ async function seedIssuedGrant( saveIssuedRoomGrant(slot, roomPath, memberId, tokenId); } -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(); - }); -}); - +// 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(); @@ -426,7 +413,10 @@ describe("RoomLifecycle — declineInvite", () => { const dmPath = dmRoomPath(h.ids.ownerId, h.ids.memberId); await expect( h.lifecycle.declineInvite(dmPath, h.ids.ownerId, "no thanks"), - ).rejects.toMatchObject({ code: "ROOM_NOT_FOUND" }); + ).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 () => { @@ -453,25 +443,7 @@ describe("RoomLifecycle — declineInvite", () => { }); }); -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(); - }); -}); - +// 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(); @@ -520,12 +492,13 @@ describe("RoomLifecycle — kickFromRoom", () => { expect(h.broadcastRevocation).toHaveBeenCalledTimes(1); }); - it("removes the target from both the member and invited lists", async () => { + 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 }, @@ -544,6 +517,9 @@ describe("RoomLifecycle — kickFromRoom", () => { "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 () => { @@ -564,6 +540,7 @@ describe("RoomLifecycle — kickFromRoom", () => { }); }); +// 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(); @@ -664,7 +641,7 @@ describe("RoomLifecycle — leaveRoom / leaveRemoteRoom", () => { }); }); - it("goes remote when this store's own agent leaves a room it does not own", async () => { + 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 })); @@ -677,6 +654,11 @@ describe("RoomLifecycle — leaveRoom / leaveRemoteRoom", () => { 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 () => { @@ -723,12 +705,15 @@ describe("RoomLifecycle — leaveRoom / leaveRemoteRoom", () => { ); h.deps.agents.set( h.ids.memberId, - agent({ id: h.ids.memberId, subscribedRooms: ["room-1"] }), + 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).not.toContain( - "room-1", - ); + expect(h.deps.agents.get(h.ids.memberId)?.subscribedRooms).toEqual([ + "other-room", + ]); expect(h.broadcastPatch).toHaveBeenCalledWith( expect.objectContaining({ type: "agent_upsert" }), ); @@ -752,19 +737,39 @@ describe("RoomLifecycle — leaveRoom / leaveRemoteRoom", () => { }); it("notifies federated links only when the room is federated", async () => { - const h = await makeHarness(); - h.deps.rooms.set( + const federated = await makeHarness(); + federated.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 }, + owner: federated.ids.ownerId, + members: [federated.ids.ownerId, federated.ids.memberId], + memberJoins: { + [federated.ids.ownerId]: 1, + [federated.ids.memberId]: 1, + }, federated: true, }), ); - await h.lifecycle.leaveRoom("room-1", h.ids.memberId); - expect(h.broadcastRoomLeave).toHaveBeenCalledWith("room-1", h.ids.memberId); + 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 () => { diff --git a/src/test/room-lifecycle-remote.test.ts b/src/test/room-lifecycle-remote.test.ts new file mode 100644 index 0000000..1736b5c --- /dev/null +++ b/src/test/room-lifecycle-remote.test.ts @@ -0,0 +1,470 @@ +/** + * Direct, DI-based unit tests for RoomLifecycle's own remote-request-sending half -- refreshRoomMembers, requestDmAccess, and getRoom, 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/declineInvite/revokeMemberGrant/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 }); + }); + + 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(); + }); +}); diff --git a/src/test/room-lifecycle.test.ts b/src/test/room-lifecycle.test.ts index b5464ac..da8f658 100644 --- a/src/test/room-lifecycle.test.ts +++ b/src/test/room-lifecycle.test.ts @@ -1,5 +1,5 @@ /** - * Direct, DI-based unit tests for RoomLifecycle -- room CRUD and the requester's own outbound half of the wire protocol, split across two files to stay under this repo's max-lines cap: this file covers createRoom, listRooms, joinRoom/joinRemoteRoom, refreshRoomMembers, and requestDmAccess. See room-lifecycle-membership.test.ts for getRoom and leaveRoom/leaveRemoteRoom (both moved there to rebalance line counts after later gap-closing passes), inviteToRoom, declineInvite, revokeMemberGrant, kickFromRoom, and destroyRoom, and its own copy of this 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. + * 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"; @@ -228,6 +228,7 @@ function roomMembersOkOutcome( } 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(); @@ -369,6 +370,7 @@ describe("RoomLifecycle — listRooms", () => { }); }); +// joinRoom's own `this.deps.rooms.set(roomId, room)` call has one provable equivalent mutant Stryker still raises: removing it. `room` here is the same object reference already fetched via `this.deps.rooms.get(roomId)`, and every mutation up to this point (bump/recordMemberOp/refreshMembership) 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(); @@ -433,7 +435,10 @@ describe("RoomLifecycle — joinRoom / joinRemoteRoom", () => { const roomPath = ownerNamedRoomPath(h.ids.memberId, "unseen"); await expect( h.lifecycle.joinRoom(roomPath, h.ids.memberId), - ).rejects.toMatchObject({ code: "ROOM_NOT_FOUND" }); + ).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.) }); @@ -442,7 +447,10 @@ describe("RoomLifecycle — joinRoom / joinRemoteRoom", () => { const dmPath = dmRoomPath(h.ids.ownerId, h.ids.memberId); await expect( h.lifecycle.joinRoom(dmPath, h.ids.ownerId), - ).rejects.toMatchObject({ code: "ROOM_NOT_FOUND" }); + ).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 () => { @@ -469,10 +477,13 @@ describe("RoomLifecycle — joinRoom / joinRemoteRoom", () => { }); await expect( h.lifecycle.joinRoom(roomPath, h.ids.ownerId), - ).rejects.toMatchObject({ code: "MALFORMED_RESPONSE" }); + ).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", async () => { + 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); @@ -492,6 +503,11 @@ describe("RoomLifecycle — joinRoom / joinRemoteRoom", () => { 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(); }); @@ -595,7 +611,7 @@ describe("RoomLifecycle — joinRoom / joinRemoteRoom", () => { ); }); - it("updates subscribedRooms and broadcasts agent_upsert only for a known agent not already subscribed", async () => { + 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", @@ -603,12 +619,12 @@ describe("RoomLifecycle — joinRoom / joinRemoteRoom", () => { ); h.deps.agents.set( h.ids.memberId, - agent({ id: h.ids.memberId, subscribedRooms: [] }), + agent({ id: h.ids.memberId, subscribedRooms: [], version: 1 }), ); await h.lifecycle.joinRoom("room-1", h.ids.memberId); - expect(h.deps.agents.get(h.ids.memberId)?.subscribedRooms).toContain( - "room-1", - ); + 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" }), ); @@ -696,155 +712,3 @@ describe("RoomLifecycle — joinRoom / joinRemoteRoom", () => { expect(plain.broadcastRoomJoin).not.toHaveBeenCalled(); }); }); - -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({ code: "MALFORMED_RESPONSE" }); - }); - - it("starts the version at 1 with 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); - }); - - it("increments the version relative to the existing local copy", async () => { - const h = await makeHarness(); - const roomPath = ownerNamedRoomPath(h.ids.memberId, "r"); - h.deps.rooms.set(roomPath, room({ id: roomPath, version: 1 })); - 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); - }); - - 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({ 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(); - }); -}); From 2c98ba0a2960f6bf6c6c8bfa38e1b15c9fb7bae6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 00:43:15 +0100 Subject: [PATCH 3/3] test(core): close four more real gaps found in a fresh RoomLifecycle run A fresh mutation run (94.49%, up from 89.76%) surfaced four further real gaps: refreshRoomMembers's invited-field fallback with no existing room was never asserted; inviteToRoom's own room.version bump, and the exact scope it sends its outbound request under, were both unobserved. Also documents two further equivalent mutants following the same same-object-reference Map.set pattern already documented elsewhere in this file: joinRoom's and leaveRoom's own agents.set(agentId, agent) calls, alongside their already-documented rooms.set(roomId, room) ones. Directly verified (by manually mutating the source locally and confirming the test suite genuinely fails) that leaveRoom's own destroy-trigger condition is correctly covered by the existing "does not destroy" test despite Stryker's own report listing it as a survivor -- not chased further, since the coverage is real. Split declineInvite and revokeMemberGrant out to room-lifecycle-remote.test.ts to stay under the repo's max-lines cap after these additions. --- src/test/room-lifecycle-membership.test.ts | 58 ++++------------------ src/test/room-lifecycle-remote.test.ts | 49 +++++++++++++++++- src/test/room-lifecycle.test.ts | 2 +- 3 files changed, 60 insertions(+), 49 deletions(-) diff --git a/src/test/room-lifecycle-membership.test.ts b/src/test/room-lifecycle-membership.test.ts index 0d816d0..8607dc8 100644 --- a/src/test/room-lifecycle-membership.test.ts +++ b/src/test/room-lifecycle-membership.test.ts @@ -1,5 +1,5 @@ /** - * Direct, DI-based unit tests for RoomLifecycle's membership-grant half -- leaveRoom/leaveRemoteRoom, inviteToRoom, declineInvite, revokeMemberGrant, kickFromRoom, and destroyRoom. Split from room-lifecycle.test.ts (createRoom/listRooms/joinRoom) and room-lifecycle-remote.test.ts (refreshRoomMembers/requestDmAccess/getRoom) 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). + * 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"; @@ -258,9 +258,12 @@ describe("RoomLifecycle — inviteToRoom", () => { }); }); - it("records an invited-join only for a target not already invited or a member", async () => { + 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 })); + h.deps.rooms.set( + "room-1", + room({ id: "room-1", owner: h.ids.ownerId, version: 1 }), + ); h.sendRoomRequest.mockResolvedValue({ result: "ok", } satisfies ManageOutcome); @@ -271,6 +274,7 @@ describe("RoomLifecycle — inviteToRoom", () => { "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 () => { @@ -385,7 +389,7 @@ describe("RoomLifecycle — inviteToRoom", () => { }); }); - it("resolves without throwing on a genuinely successful invite", async () => { + 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({ @@ -394,51 +398,10 @@ describe("RoomLifecycle — inviteToRoom", () => { await expect( h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId), ).resolves.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, + expect.anything(), + { kind: "room", path: "room-1" }, ); }); }); @@ -630,6 +593,7 @@ describe("RoomLifecycle — destroyRoom", () => { }); }); }); +// 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(); diff --git a/src/test/room-lifecycle-remote.test.ts b/src/test/room-lifecycle-remote.test.ts index 1736b5c..740d669 100644 --- a/src/test/room-lifecycle-remote.test.ts +++ b/src/test/room-lifecycle-remote.test.ts @@ -1,5 +1,5 @@ /** - * Direct, DI-based unit tests for RoomLifecycle's own remote-request-sending half -- refreshRoomMembers, requestDmAccess, and getRoom, 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/declineInvite/revokeMemberGrant/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. + * 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"; @@ -315,6 +315,7 @@ describe("RoomLifecycle — refreshRoomMembers", () => { 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 () => { @@ -468,3 +469,49 @@ describe("RoomLifecycle — revokeMemberGrant", () => { 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 index da8f658..c375158 100644 --- a/src/test/room-lifecycle.test.ts +++ b/src/test/room-lifecycle.test.ts @@ -370,7 +370,7 @@ describe("RoomLifecycle — listRooms", () => { }); }); -// joinRoom's own `this.deps.rooms.set(roomId, room)` call has one provable equivalent mutant Stryker still raises: removing it. `room` here is the same object reference already fetched via `this.deps.rooms.get(roomId)`, and every mutation up to this point (bump/recordMemberOp/refreshMembership) 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)). +// 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();