fix: harden collaboration room access (#1332)

* fix: harden collaboration room access

* fix: confirm guest link lifecycle changes
This commit is contained in:
ZacharyZcR
2026-08-25 01:36:19 +08:00
committed by GitHub
parent c51c3a9449
commit 8260af2d57
11 changed files with 619 additions and 77 deletions
@@ -72,6 +72,7 @@ vi.mock("../../../hosts/terminal/session-manager.js", () => ({
setRoomShareControl: (...args: unknown[]) => {
state.control.push(args);
},
disconnectShareParticipants: vi.fn(() => 0),
},
}));
vi.mock("../../../hosts/session-sharing/live-sessions.js", () => ({
@@ -147,6 +148,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({
updateStage: async (roomId: string, stage: Partial<Room>) => {
Object.assign(state.rooms.get(roomId)!, stage);
},
replaceStage: async (
roomId: string,
expectedShareId: string | null,
stage: Partial<Room>,
) => {
const room = state.rooms.get(roomId)!;
if (room.stageShareId !== expectedShareId) return false;
Object.assign(room, stage);
return true;
},
clearStage: async (roomId: string) => {
Object.assign(state.rooms.get(roomId)!, {
presenterUserId: null,
@@ -307,6 +318,26 @@ describe("collab room routes", () => {
expect((other as { statusCode: number }).statusCode).toBe(404);
});
it("never exposes the guest bearer token in room responses", async () => {
const roomId = await createRoom();
await invite(roomId, ["alice"]);
state.rooms.get(roomId)!.guestLinkToken = "secret-token";
const list = await as("alice", () => invoke("get", "/rooms"));
const listedRoom = (list as Awaited<ReturnType<typeof invoke>>).jsonBody!
.rooms as Array<Record<string, unknown>>;
expect(listedRoom[0]).not.toHaveProperty("guestLinkToken");
expect(listedRoom[0]).toHaveProperty("guestLinkEnabled", true);
const detail = await as("alice", () =>
invoke("get", "/rooms/:id", { params: { id: roomId } }),
);
const room = (detail as Awaited<ReturnType<typeof invoke>>).jsonBody!
.room as Record<string, unknown>;
expect(room).not.toHaveProperty("guestLinkToken");
expect(room).toHaveProperty("guestLinkEnabled", true);
});
it("rejects an empty or oversized room name", async () => {
expect(
(await invoke("post", "/rooms", { body: { name: " " } })).statusCode,
@@ -57,6 +57,8 @@ function makeFakeWs(readyState = 1 /* OPEN */) {
return {
readyState,
send: vi.fn(),
close: vi.fn(),
terminate: vi.fn(),
} as unknown as import("ws").WebSocket;
}
const WS_OPEN = 1;
@@ -254,6 +256,39 @@ describe("TerminalSessionManager - multiplayer participants", () => {
sessionManager.destroySession(id);
});
it("disconnectShareParticipants revokes only the selected share participants", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
const aliceWs = makeFakeWs();
const guestWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const session = sessionManager.joinAsParticipant(id, aliceWs, {
userId: "alice",
permissionLevel: "read-only",
shareId: "stage-share",
})!;
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
shareId: "stage-share",
});
expect(
sessionManager.disconnectShareParticipants(id, "stage-share", {
userId: "alice",
reason: "Removed",
}),
).toBe(1);
expect(sessionManager.getParticipantForWs(session, aliceWs)).toBeNull();
expect(sessionManager.getParticipantForWs(session, guestWs)).not.toBeNull();
expect(sessionManager.getParticipantForWs(session, ownerWs)?.isOwner).toBe(
true,
);
expect(aliceWs.close).toHaveBeenCalledWith(1008, "Removed");
sessionManager.destroySession(id);
});
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
expect(
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {