mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: move session sharing and collab rooms into a plugin (B12)
This commit is contained in:
1 parent
791d8656a1
commit
a276f6898c
207 files changed
+35771
-11072
No files matched your search
@@ -1,113 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
listener: null as ((roomId: string, message: object) => void) | null,
|
||||
publish: vi.fn(async () => undefined),
|
||||
updatePresence: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/collab/runtime-store.js", () => ({
|
||||
collabRuntimeStore: {
|
||||
onEvent: (listener: (roomId: string, message: object) => void) => {
|
||||
runtime.listener = listener;
|
||||
},
|
||||
publish: runtime.publish,
|
||||
updatePresence: runtime.updatePresence,
|
||||
onlineUsers: async (
|
||||
_roomId: string,
|
||||
users: Array<{ userId: string; username: string }>,
|
||||
) => users,
|
||||
},
|
||||
}));
|
||||
|
||||
import { collabRoomHub } from "../../../hosts/collab/room-hub.js";
|
||||
|
||||
function fakeWs(open = true): WebSocket {
|
||||
return {
|
||||
OPEN: 1,
|
||||
readyState: open ? 1 : 3,
|
||||
send: vi.fn(),
|
||||
} as unknown as WebSocket;
|
||||
}
|
||||
|
||||
describe("collabRoomHub", () => {
|
||||
it("announces the online list on subscribe and unsubscribe, deduplicated per user", async () => {
|
||||
const a1 = fakeWs();
|
||||
const a2 = fakeWs();
|
||||
const b = fakeWs();
|
||||
collabRoomHub.subscribe("room-1", { ws: a1, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-1", { ws: a2, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-1", { ws: b, userId: "b", username: "B" });
|
||||
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
{ userId: "a", username: "A" },
|
||||
{ userId: "b", username: "B" },
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => expect(b.send).toHaveBeenCalled());
|
||||
const last = JSON.parse(
|
||||
(b.send as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0] as string,
|
||||
);
|
||||
expect(last).toEqual({
|
||||
type: "collab_online",
|
||||
roomId: "room-1",
|
||||
users: [
|
||||
{ userId: "a", username: "A" },
|
||||
{ userId: "b", username: "B" },
|
||||
],
|
||||
});
|
||||
|
||||
collabRoomHub.unsubscribe(a1);
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toHaveLength(2);
|
||||
collabRoomHub.unsubscribe(a2);
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
{ userId: "b", username: "B" },
|
||||
]);
|
||||
collabRoomHub.unsubscribe(b);
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("subscribing the same socket twice keeps one subscription", async () => {
|
||||
const ws = fakeWs();
|
||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||
await vi.waitFor(() =>
|
||||
expect((ws.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1),
|
||||
);
|
||||
collabRoomHub.unsubscribe(ws);
|
||||
});
|
||||
|
||||
it("broadcast skips closed sockets and rooms nobody watches", () => {
|
||||
const open = fakeWs();
|
||||
const closed = fakeWs(false);
|
||||
collabRoomHub.subscribe("room-3", { ws: open, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-3", {
|
||||
ws: closed,
|
||||
userId: "b",
|
||||
username: "B",
|
||||
});
|
||||
(open.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
(closed.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
collabRoomHub.broadcast("room-3", { type: "collab_stage_changed" });
|
||||
collabRoomHub.broadcast("nobody", { type: "collab_stage_changed" });
|
||||
|
||||
expect(open.send).toHaveBeenCalledTimes(1);
|
||||
expect(closed.send).not.toHaveBeenCalled();
|
||||
collabRoomHub.unsubscribe(open);
|
||||
collabRoomHub.unsubscribe(closed);
|
||||
});
|
||||
|
||||
it("fans out remote Redis events but keeps internal events server-side", () => {
|
||||
const ws = fakeWs();
|
||||
collabRoomHub.subscribe("room-4", { ws, userId: "a", username: "A" });
|
||||
(ws.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
runtime.listener?.("room-4", { type: "collab_members_changed" });
|
||||
runtime.listener?.("room-4", { type: "collab_internal_stage_revoked" });
|
||||
|
||||
expect(ws.send).toHaveBeenCalledTimes(1);
|
||||
collabRoomHub.unsubscribe(ws);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
roomsByShare: new Map<string, { id: string }>(),
|
||||
members: new Set<string>(), // `${roomId}:${userId}`
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentCollabRoomRepository: () => ({
|
||||
findByStageShareId: async (shareId: string) =>
|
||||
state.roomsByShare.get(shareId) ?? null,
|
||||
findMember: async (roomId: string, userId: string) =>
|
||||
state.members.has(`${roomId}:${userId}`) ? { roomId, userId } : null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { canJoinRoomStageShare } =
|
||||
await import("../../../hosts/collab/room-share-access.js");
|
||||
|
||||
describe("canJoinRoomStageShare", () => {
|
||||
beforeEach(() => {
|
||||
state.roomsByShare.clear();
|
||||
state.members.clear();
|
||||
});
|
||||
|
||||
it("admits members of the live room whose stage is this share", async () => {
|
||||
state.roomsByShare.set("share-1", { id: "room-1" });
|
||||
state.members.add("room-1:alice");
|
||||
await expect(canJoinRoomStageShare("share-1", "alice")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-members and shares that are not a room stage", async () => {
|
||||
state.roomsByShare.set("share-1", { id: "room-1" });
|
||||
await expect(canJoinRoomStageShare("share-1", "mallory")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(canJoinRoomStageShare("share-9", "alice")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,689 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
type Room = {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
guestLinkToken: string | null;
|
||||
createdAt: string;
|
||||
endedAt: string | null;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "host-1",
|
||||
rooms: new Map<string, Room>(),
|
||||
members: new Map<
|
||||
string,
|
||||
{ roomId: string; userId: string; roomRole: string }
|
||||
>(),
|
||||
shares: new Map<string, Record<string, unknown>>(),
|
||||
users: new Set<string>(["host-1", "alice", "bob"]),
|
||||
roles: new Map<number, string[]>([[7, ["alice", "carol"]]]),
|
||||
sharingEnabled: true,
|
||||
liveOwned: new Map<string, string>(), // sessionId -> owner
|
||||
broadcasts: [] as Array<Record<string, unknown>>,
|
||||
control: [] as Array<unknown[]>,
|
||||
controllers: new Map<string, string>(),
|
||||
requests: new Map<
|
||||
string,
|
||||
Map<string, { userId: string; username: string; requestedAt: string }>
|
||||
>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => undefined),
|
||||
getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../../hosts/guacamole-sessions.js", () => ({
|
||||
createGuacamoleJoinToken: (id: string, readOnly: boolean) =>
|
||||
`join:${id}:${readOnly}`,
|
||||
}));
|
||||
vi.mock("../../../hosts/collab/room-hub.js", () => ({
|
||||
collabRoomHub: {
|
||||
broadcast: (roomId: string, message: Record<string, unknown>) => {
|
||||
state.broadcasts.push({ roomId, ...message });
|
||||
},
|
||||
onlineUsers: () => [],
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/collab/runtime-store.js", () => ({
|
||||
collabRuntimeStore: {
|
||||
onEvent: vi.fn(),
|
||||
publish: vi.fn(async () => undefined),
|
||||
getController: async (roomId: string) =>
|
||||
state.controllers.get(roomId) ?? null,
|
||||
setController: async (roomId: string, userId: string | null) => {
|
||||
if (userId) state.controllers.set(roomId, userId);
|
||||
else state.controllers.delete(roomId);
|
||||
},
|
||||
listRequests: async (roomId: string) =>
|
||||
Array.from(state.requests.get(roomId)?.values() ?? []).sort((a, b) =>
|
||||
a.requestedAt.localeCompare(b.requestedAt),
|
||||
),
|
||||
upsertRequest: async (
|
||||
roomId: string,
|
||||
request: { userId: string; username: string; requestedAt: string },
|
||||
) => {
|
||||
let requests = state.requests.get(roomId);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
state.requests.set(roomId, requests);
|
||||
}
|
||||
requests.set(request.userId, request);
|
||||
},
|
||||
removeRequest: async (roomId: string, userId: string) => {
|
||||
state.requests.get(roomId)?.delete(userId);
|
||||
},
|
||||
clearRequests: async (roomId: string) => {
|
||||
state.requests.delete(roomId);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/live-terminal-sessions.js", () => ({
|
||||
liveTerminalSessions: {
|
||||
setRoomShareControl: (...args: unknown[]) => {
|
||||
state.control.push(args);
|
||||
},
|
||||
disconnectShareParticipants: vi.fn(() => 0),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/session-sharing/live-sessions.js", () => ({
|
||||
isSharingEnabledForHost: async () => ({
|
||||
enabled: state.sharingEnabled,
|
||||
hostOwnerId: "host-1",
|
||||
}),
|
||||
isLiveSessionOwnedBy: (_p: string, sessionId: string, userId: string) =>
|
||||
state.liveOwned.get(sessionId) === userId,
|
||||
isLiveSession: (_p: string, sessionId: string) =>
|
||||
state.liveOwned.has(sessionId),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentCollabRoomRepository: () => ({
|
||||
createRoom: async (
|
||||
input: Omit<
|
||||
Room,
|
||||
| "presenterUserId"
|
||||
| "stageProtocol"
|
||||
| "stageHostId"
|
||||
| "stageShareId"
|
||||
| "guestLinkToken"
|
||||
| "createdAt"
|
||||
| "endedAt"
|
||||
>,
|
||||
) => {
|
||||
const room: Room = {
|
||||
...input,
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
guestLinkToken: null,
|
||||
createdAt: "2026-08-25T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
};
|
||||
state.rooms.set(room.id, room);
|
||||
return room;
|
||||
},
|
||||
findById: async (id: string) => state.rooms.get(id) ?? null,
|
||||
findByGuestToken: async (token: string) =>
|
||||
[...state.rooms.values()].find(
|
||||
(r) => r.guestLinkToken === token && !r.endedAt,
|
||||
) ?? null,
|
||||
setGuestToken: async (roomId: string, token: string | null) => {
|
||||
state.rooms.get(roomId)!.guestLinkToken = token;
|
||||
},
|
||||
listForUser: async (userId: string) =>
|
||||
[...state.members.values()]
|
||||
.filter((m) => m.userId === userId)
|
||||
.map((m) => state.rooms.get(m.roomId)!)
|
||||
.filter((r) => !r.endedAt),
|
||||
findMember: async (roomId: string, userId: string) =>
|
||||
state.members.get(`${roomId}:${userId}`) ?? null,
|
||||
addMember: async (input: {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
roomRole: string;
|
||||
}) => {
|
||||
const key = `${input.roomId}:${input.userId}`;
|
||||
if (state.members.has(key)) return false;
|
||||
state.members.set(key, input);
|
||||
return true;
|
||||
},
|
||||
removeMember: async (roomId: string, userId: string) => {
|
||||
state.members.delete(`${roomId}:${userId}`);
|
||||
},
|
||||
listMembers: async (roomId: string) =>
|
||||
[...state.members.values()]
|
||||
.filter((m) => m.roomId === roomId)
|
||||
.map((m) => ({ ...m, username: m.userId, createdAt: "" })),
|
||||
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,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
},
|
||||
endRoom: async (roomId: string) => {
|
||||
Object.assign(state.rooms.get(roomId)!, {
|
||||
endedAt: "2026-08-25T01:00:00.000Z",
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
},
|
||||
}),
|
||||
createCurrentSessionShareRepository: () => ({
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
const row = { ...input, revokedAt: null };
|
||||
state.shares.set(input.id as string, row);
|
||||
return row;
|
||||
},
|
||||
findActiveById: async (id: string) => {
|
||||
const share = state.shares.get(id);
|
||||
return share && !share.revokedAt ? share : null;
|
||||
},
|
||||
revokeAsAdmin: async (id: string) => {
|
||||
const share = state.shares.get(id);
|
||||
if (!share) return false;
|
||||
share.revokedAt = "now";
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => (state.users.has(id) ? { id } : null),
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
findRoleById: async (id: number) => (state.roles.has(id) ? { id } : null),
|
||||
listRoleUserIds: async (id: number) => state.roles.get(id) ?? [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: router } = await import("../../../hosts/collab/routes.js");
|
||||
const { getStageController } =
|
||||
await import("../../../hosts/collab/stage-control.js");
|
||||
|
||||
type RouteLayer = {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: {
|
||||
handle: (req: Request, res: Response, next: () => void) => unknown;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
} = {},
|
||||
) {
|
||||
const layers = (router as unknown as { stack: RouteLayer[] }).stack;
|
||||
const layer = layers.find(
|
||||
(l) => l.route?.path === path && l.route.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
const req = {
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
headers: {},
|
||||
ip: overrides.ip ?? "127.0.0.1",
|
||||
socket: { remoteAddress: overrides.ip ?? "127.0.0.1" },
|
||||
} as unknown as Request;
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
for (const handler of layer.route.stack) {
|
||||
let calledNext = false;
|
||||
await handler.handle(req, res as unknown as Response, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
if (!calledNext) break;
|
||||
}
|
||||
return res as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
async function as(userId: string, run: () => Promise<unknown>) {
|
||||
const previous = state.currentUserId;
|
||||
state.currentUserId = userId;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
state.currentUserId = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRoom(persistent = false): Promise<string> {
|
||||
const response = await invoke("post", "/rooms", {
|
||||
body: { name: "Standup", persistent },
|
||||
});
|
||||
return (response.jsonBody!.room as Room).id;
|
||||
}
|
||||
|
||||
async function invite(roomId: string, userIds: string[]) {
|
||||
return invoke("post", "/rooms/:id/members", {
|
||||
params: { id: roomId },
|
||||
body: { userIds },
|
||||
});
|
||||
}
|
||||
|
||||
async function present(roomId: string, sessionId: string, protocol = "ssh") {
|
||||
return invoke("post", "/rooms/:id/present", {
|
||||
params: { id: roomId },
|
||||
body: { protocol, sessionId, hostId: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("collab room routes", () => {
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "host-1";
|
||||
state.rooms.clear();
|
||||
state.members.clear();
|
||||
state.shares.clear();
|
||||
state.liveOwned.clear();
|
||||
state.broadcasts.length = 0;
|
||||
state.control.length = 0;
|
||||
state.controllers.clear();
|
||||
state.requests.clear();
|
||||
state.sharingEnabled = true;
|
||||
});
|
||||
|
||||
it("creating a room makes the creator its host and lists it for them only", async () => {
|
||||
const roomId = await createRoom();
|
||||
expect(state.members.get(`${roomId}:host-1`)?.roomRole).toBe("host");
|
||||
|
||||
const mine = await invoke("get", "/rooms");
|
||||
expect((mine.jsonBody!.rooms as Room[]).map((r) => r.id)).toEqual([roomId]);
|
||||
|
||||
const other = await as("alice", () =>
|
||||
invoke("get", "/rooms/:id", { params: { id: roomId } }),
|
||||
);
|
||||
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,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(await invoke("post", "/rooms", { body: { name: "x".repeat(121) } }))
|
||||
.statusCode,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("only the host invites; roles expand to their members; unknown users 404", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
const byMember = await as("alice", () => invite(roomId, ["bob"]));
|
||||
expect((byMember as { statusCode: number }).statusCode).toBe(403);
|
||||
|
||||
expect((await invite(roomId, ["nobody"])).statusCode).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await invoke("post", "/rooms/:id/members", {
|
||||
params: { id: roomId },
|
||||
body: {},
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(400);
|
||||
|
||||
const byRole = await invoke("post", "/rooms/:id/members", {
|
||||
params: { id: roomId },
|
||||
body: { roleIds: [7] },
|
||||
});
|
||||
expect(byRole.statusCode).toBe(200);
|
||||
expect(state.members.has(`${roomId}:carol`)).toBe(true);
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_members_changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("members may leave, only the host removes others, the owner is never removed", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice", "bob"]);
|
||||
|
||||
const aliceRemovesBob = await as("alice", () =>
|
||||
invoke("delete", "/rooms/:id/members/:userId", {
|
||||
params: { id: roomId, userId: "bob" },
|
||||
}),
|
||||
);
|
||||
expect((aliceRemovesBob as { statusCode: number }).statusCode).toBe(403);
|
||||
|
||||
await as("alice", () =>
|
||||
invoke("delete", "/rooms/:id/members/:userId", {
|
||||
params: { id: roomId, userId: "alice" },
|
||||
}),
|
||||
);
|
||||
expect(state.members.has(`${roomId}:alice`)).toBe(false);
|
||||
|
||||
const removeOwner = await invoke("delete", "/rooms/:id/members/:userId", {
|
||||
params: { id: roomId, userId: "host-1" },
|
||||
});
|
||||
expect(removeOwner.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("presenting validates the protocol, the sharing toggle and live-session ownership", async () => {
|
||||
const roomId = await createRoom();
|
||||
expect((await present(roomId, "s1", "ftp")).statusCode).toBe(400);
|
||||
expect((await present(roomId, "s1")).statusCode).toBe(403); // not live
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
state.sharingEnabled = false;
|
||||
expect((await present(roomId, "s1")).statusCode).toBe(403);
|
||||
state.sharingEnabled = true;
|
||||
|
||||
const ok = await present(roomId, "s1");
|
||||
expect(ok.statusCode).toBe(200);
|
||||
const room = state.rooms.get(roomId)!;
|
||||
expect(room.presenterUserId).toBe("host-1");
|
||||
expect(state.shares.get(room.stageShareId!)).toMatchObject({
|
||||
shareType: "room",
|
||||
permissionLevel: "read-only",
|
||||
sessionId: "s1",
|
||||
});
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_stage_changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("a takeover revokes the previous stage share and clears control", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
state.liveOwned.set("s2", "alice");
|
||||
await present(roomId, "s1");
|
||||
const firstShare = state.rooms.get(roomId)!.stageShareId!;
|
||||
await invoke("post", "/rooms/:id/control", {
|
||||
params: { id: roomId },
|
||||
body: { userId: "alice" },
|
||||
});
|
||||
expect(await getStageController(roomId)).toBe("alice");
|
||||
|
||||
await as("alice", () => present(roomId, "s2"));
|
||||
const room = state.rooms.get(roomId)!;
|
||||
expect(room.presenterUserId).toBe("alice");
|
||||
expect(state.shares.get(firstShare)!.revokedAt).toBeTruthy();
|
||||
expect(await getStageController(roomId)).toBeNull();
|
||||
});
|
||||
|
||||
it("stop is for the presenter or host", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice", "bob"]);
|
||||
state.liveOwned.set("s2", "alice");
|
||||
await as("alice", () => present(roomId, "s2"));
|
||||
|
||||
const bob = await as("bob", () =>
|
||||
invoke("post", "/rooms/:id/stop", { params: { id: roomId } }),
|
||||
);
|
||||
expect((bob as { statusCode: number }).statusCode).toBe(403);
|
||||
await invoke("post", "/rooms/:id/stop", { params: { id: roomId } }); // host
|
||||
expect(state.rooms.get(roomId)!.stageShareId).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps remote desktop stages read-only", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
const stageOf = (user: string) =>
|
||||
as(user, () =>
|
||||
invoke("get", "/rooms/:id/stage", { params: { id: roomId } }),
|
||||
) as Promise<{
|
||||
jsonBody: { stage: Record<string, unknown> | null };
|
||||
}>;
|
||||
|
||||
expect((await stageOf("alice")).jsonBody.stage).toBeNull();
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
const ssh = (await stageOf("alice")).jsonBody.stage!;
|
||||
expect(ssh).toMatchObject({ protocol: "ssh", sessionId: "s1" });
|
||||
expect(ssh.connectParams).toBeUndefined();
|
||||
|
||||
state.liveOwned.set("g1", "host-1");
|
||||
await present(roomId, "g1", "rdp");
|
||||
expect((await stageOf("alice")).jsonBody.stage!.connectParams).toEqual({
|
||||
token: "join:g1:true",
|
||||
});
|
||||
const control = await invoke("post", "/rooms/:id/control", {
|
||||
params: { id: roomId },
|
||||
body: { userId: "alice" },
|
||||
});
|
||||
expect(control.statusCode).toBe(400);
|
||||
expect((await stageOf("alice")).jsonBody.stage!.connectParams).toEqual({
|
||||
token: "join:g1:true",
|
||||
});
|
||||
});
|
||||
|
||||
it("a dead presenter session clears the stale stage on resolve", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
state.liveOwned.delete("s1");
|
||||
|
||||
const resolved = await as("alice", () =>
|
||||
invoke("get", "/rooms/:id/stage", { params: { id: roomId } }),
|
||||
);
|
||||
expect(
|
||||
(resolved as { jsonBody: { stage: unknown } }).jsonBody.stage,
|
||||
).toBeNull();
|
||||
expect(state.rooms.get(roomId)!.stageShareId).toBeNull();
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_stage_changed",
|
||||
stage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("control: presenter/host grant, controller releases, members may only ask", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice", "bob"]);
|
||||
const control = (user: string, target: string | null) =>
|
||||
as(user, () =>
|
||||
invoke("post", "/rooms/:id/control", {
|
||||
params: { id: roomId },
|
||||
body: { userId: target },
|
||||
}),
|
||||
) as Promise<{ statusCode: number }>;
|
||||
|
||||
expect((await control("host-1", "alice")).statusCode).toBe(400); // nothing presented
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
expect((await control("bob", "bob")).statusCode).toBe(403);
|
||||
expect((await control("host-1", "nobody")).statusCode).toBe(404);
|
||||
expect((await control("host-1", "alice")).statusCode).toBe(200);
|
||||
expect(state.control.at(-1)).toEqual([
|
||||
"s1",
|
||||
state.rooms.get(roomId)!.stageShareId,
|
||||
"alice",
|
||||
]);
|
||||
expect(state.broadcasts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "collab_control_changed",
|
||||
controllerUserId: "alice",
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await control("bob", null)).statusCode).toBe(403);
|
||||
expect((await control("alice", null)).statusCode).toBe(200);
|
||||
expect(await getStageController(roomId)).toBeNull();
|
||||
|
||||
const asked = await as("bob", () =>
|
||||
invoke("post", "/rooms/:id/control/request", { params: { id: roomId } }),
|
||||
);
|
||||
expect((asked as { statusCode: number }).statusCode).toBe(200);
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_control_requested",
|
||||
userId: "bob",
|
||||
username: "BOB",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await as("bob", () =>
|
||||
invoke("get", "/rooms/:id", { params: { id: roomId } }),
|
||||
)
|
||||
).jsonBody!.controlRequests,
|
||||
).toEqual([expect.objectContaining({ userId: "bob" })]);
|
||||
|
||||
const repeated = await as("bob", () =>
|
||||
invoke("post", "/rooms/:id/control/request", {
|
||||
params: { id: roomId },
|
||||
}),
|
||||
);
|
||||
expect((repeated as { statusCode: number }).statusCode).toBe(429);
|
||||
|
||||
const listed = await invoke("get", "/rooms/:id/control/requests", {
|
||||
params: { id: roomId },
|
||||
});
|
||||
expect(listed.jsonBody!.requests).toEqual([
|
||||
expect.objectContaining({ userId: "bob" }),
|
||||
]);
|
||||
await invoke("delete", "/rooms/:id/control/requests/:userId", {
|
||||
params: { id: roomId, userId: "bob" },
|
||||
});
|
||||
expect(state.requests.get(roomId)?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("guest link: host-only toggle, anonymous resolve follows the stage, rate limited", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
const toggle = (user: string, enabled: boolean) =>
|
||||
as(user, () =>
|
||||
invoke("post", "/rooms/:id/guest-link", {
|
||||
params: { id: roomId },
|
||||
body: { enabled },
|
||||
}),
|
||||
) as Promise<{
|
||||
statusCode: number;
|
||||
jsonBody: { guestLinkToken: string | null };
|
||||
}>;
|
||||
|
||||
expect((await toggle("alice", true)).statusCode).toBe(403);
|
||||
const { guestLinkToken } = (await toggle("host-1", true)).jsonBody;
|
||||
expect(guestLinkToken).toBeTruthy();
|
||||
|
||||
const resolve = (token: string, ip = "9.9.9.9") =>
|
||||
invoke("get", "/guest/:token", { params: { token }, ip });
|
||||
expect((await resolve("nope")).statusCode).toBe(404);
|
||||
expect((await resolve(guestLinkToken!)).jsonBody).toEqual({
|
||||
roomName: "Standup",
|
||||
stage: null,
|
||||
});
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({
|
||||
protocol: "ssh",
|
||||
wsPath: `/plugin-ws/ssh-terminal/terminal?roomGuestToken=${encodeURIComponent(guestLinkToken!)}`,
|
||||
});
|
||||
state.liveOwned.set("g1", "host-1");
|
||||
await present(roomId, "g1", "vnc");
|
||||
expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({
|
||||
protocol: "vnc",
|
||||
connectParams: { token: "join:g1:true" },
|
||||
});
|
||||
|
||||
await toggle("host-1", false);
|
||||
expect((await resolve(guestLinkToken!)).statusCode).toBe(404);
|
||||
|
||||
let last = 200;
|
||||
for (let i = 0; i < 61; i++) {
|
||||
last = (await resolve("x", "1.2.3.4")).statusCode;
|
||||
}
|
||||
expect(last).toBe(429);
|
||||
});
|
||||
|
||||
it("ending a one-off room ends it; ending a persistent room only clears the stage", async () => {
|
||||
const oneOff = await createRoom(false);
|
||||
await invite(oneOff, ["alice"]);
|
||||
const byMember = await as("alice", () =>
|
||||
invoke("post", "/rooms/:id/end", { params: { id: oneOff } }),
|
||||
);
|
||||
expect((byMember as { statusCode: number }).statusCode).toBe(403);
|
||||
await invoke("post", "/rooms/:id/end", { params: { id: oneOff } });
|
||||
expect(state.rooms.get(oneOff)!.endedAt).toBeTruthy();
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_room_ended",
|
||||
});
|
||||
|
||||
const persistent = await createRoom(true);
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(persistent, "s1");
|
||||
await invoke("post", "/rooms/:id/end", { params: { id: persistent } });
|
||||
const room = state.rooms.get(persistent)!;
|
||||
expect(room.endedAt).toBeNull();
|
||||
expect(room.stageShareId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collabRuntimeStore } from "../../../hosts/collab/runtime-store.js";
|
||||
|
||||
describe("collabRuntimeStore local fallback", () => {
|
||||
it("stores stage control and an ordered, deduplicated request queue", async () => {
|
||||
const roomId = `fallback-${crypto.randomUUID()}`;
|
||||
|
||||
await collabRuntimeStore.setController(roomId, "alice");
|
||||
expect(await collabRuntimeStore.getController(roomId)).toBe("alice");
|
||||
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "bob",
|
||||
username: "Bob",
|
||||
requestedAt: "2026-08-25T00:00:02.000Z",
|
||||
});
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "alice",
|
||||
username: "Alice",
|
||||
requestedAt: "2026-08-25T00:00:01.000Z",
|
||||
});
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "bob",
|
||||
username: "Bob",
|
||||
requestedAt: "2026-08-25T00:00:03.000Z",
|
||||
});
|
||||
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toEqual([
|
||||
expect.objectContaining({ userId: "alice" }),
|
||||
expect.objectContaining({ userId: "bob" }),
|
||||
]);
|
||||
await collabRuntimeStore.removeRequest(roomId, "alice");
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toHaveLength(1);
|
||||
|
||||
await collabRuntimeStore.clearRequests(roomId);
|
||||
await collabRuntimeStore.setController(roomId, null);
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toEqual([]);
|
||||
expect(await collabRuntimeStore.getController(roomId)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,508 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "user-1",
|
||||
globalSharingEnabled: true,
|
||||
hosts: new Map<number, { userId: string; allowSessionSharing: boolean }>(),
|
||||
hostOwnerAccess: new Map<string, boolean>(), // `${userId}:${hostId}` -> hasAccess
|
||||
sshSessions: new Map<string, { userId: string; isConnected: boolean }>(),
|
||||
guacSessions: new Map<
|
||||
string,
|
||||
{ ownerUserId: string; hostId: number; protocol: string }
|
||||
>(),
|
||||
shares: new Map<string, Record<string, unknown>>(),
|
||||
admins: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
_action: string,
|
||||
) => ({
|
||||
hasAccess: state.hostOwnerAccess.get(`${userId}:${hostId}`) ?? false,
|
||||
}),
|
||||
isAdmin: async (userId: string) => state.admins.has(userId),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/live-terminal-sessions.js", () => ({
|
||||
liveTerminalSessions: {
|
||||
getSession: (sessionId: string) => {
|
||||
const session = state.sshSessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
return { ...session };
|
||||
},
|
||||
ownerEndSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/guacamole-sessions.js", () => ({
|
||||
getGuacamoleSessionInfo: (guacamoleConnectionId: string) =>
|
||||
state.guacSessions.get(guacamoleConnectionId) ?? null,
|
||||
createGuacamoleJoinToken: (
|
||||
guacamoleConnectionId: string,
|
||||
readOnly: boolean,
|
||||
) => `join-token:${guacamoleConnectionId}:${readOnly}`,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSessionShareRepository: () => ({
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
const row = {
|
||||
...input,
|
||||
createdAt: "2026-07-20T00:00:00.000Z",
|
||||
revokedAt: null,
|
||||
lastJoinedAt: null,
|
||||
joinCount: 0,
|
||||
};
|
||||
state.shares.set(input.id as string, row);
|
||||
return row;
|
||||
},
|
||||
findById: async (id: string) => state.shares.get(id) ?? null,
|
||||
findByLinkToken: async (linkToken: string) => {
|
||||
for (const share of state.shares.values()) {
|
||||
if (
|
||||
share.linkToken === linkToken &&
|
||||
!share.revokedAt &&
|
||||
(share.expiresAt as string) > new Date().toISOString()
|
||||
) {
|
||||
return share;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
findActiveSharesForHost: async (hostId: number, ownerUserId: string) => {
|
||||
return [...state.shares.values()].filter(
|
||||
(s) =>
|
||||
s.hostId === hostId && s.ownerUserId === ownerUserId && !s.revokedAt,
|
||||
);
|
||||
},
|
||||
revoke: async (shareId: string, requestingUserId: string) => {
|
||||
const share = state.shares.get(shareId);
|
||||
if (!share || share.ownerUserId !== requestingUserId) return false;
|
||||
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||
return true;
|
||||
},
|
||||
revokeAsAdmin: async (shareId: string) => {
|
||||
const share = state.shares.get(shareId);
|
||||
if (!share) return false;
|
||||
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||
return true;
|
||||
},
|
||||
touchShareUsage: async () => {},
|
||||
recordParticipantJoin: async () => ({ id: 1 }),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
getBoolean: async () => state.globalSharingEnabled,
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async (hostId: number) =>
|
||||
state.hosts.get(hostId)?.userId ?? null,
|
||||
findHostById: async (hostId: number) => {
|
||||
const host = state.hosts.get(hostId);
|
||||
if (!host) return null;
|
||||
return { allowSessionSharing: host.allowSessionSharing };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../hosts/session-sharing/routes.js");
|
||||
|
||||
type RouteLayer = {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: {
|
||||
handle: (req: Request, res: Response, next: () => void) => unknown;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
function findHandlers(method: string, path: string) {
|
||||
const layers = (router as unknown as { stack: RouteLayer[] }).stack;
|
||||
const layer = layers.find(
|
||||
(l) => l.route?.path === path && l.route.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
return layer.route.stack.map((s) => s.handle);
|
||||
}
|
||||
|
||||
function makeReqRes(overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
}) {
|
||||
const req = {
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
headers: {},
|
||||
ip: overrides.ip ?? "127.0.0.1",
|
||||
socket: { remoteAddress: overrides.ip ?? "127.0.0.1" },
|
||||
} as unknown as Request;
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
(this as unknown as { statusCode: number }).statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
} = {},
|
||||
) {
|
||||
const handlers = findHandlers(method, path);
|
||||
const { req, res } = makeReqRes(overrides);
|
||||
|
||||
for (const handler of handlers) {
|
||||
let calledNext = false;
|
||||
await handler(req, res, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
if (!calledNext) break;
|
||||
}
|
||||
|
||||
return res as unknown as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "user-1";
|
||||
state.globalSharingEnabled = true;
|
||||
state.hosts = new Map([
|
||||
[1, { userId: "user-1", allowSessionSharing: true }],
|
||||
[2, { userId: "user-1", allowSessionSharing: false }],
|
||||
]);
|
||||
state.hostOwnerAccess = new Map([["user-2:1", true]]);
|
||||
state.sshSessions = new Map([
|
||||
["session-1", { userId: "user-1", isConnected: true }],
|
||||
]);
|
||||
state.guacSessions = new Map([
|
||||
["guac-conn-1", { ownerUserId: "user-1", hostId: 1, protocol: "vnc" }],
|
||||
]);
|
||||
state.shares = new Map();
|
||||
state.admins = new Set();
|
||||
});
|
||||
|
||||
describe("POST /session-sharing/create", () => {
|
||||
it("rejects a caller who does not own the live session", async () => {
|
||||
state.currentUserId = "user-2";
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "You do not own this live session",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a link share for the session owner", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ shareId: expect.any(String) });
|
||||
expect((res.jsonBody as Record<string, unknown>).linkToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects a user share when the target lacks host access", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "user",
|
||||
targetUserId: "no-access-user",
|
||||
permissionLevel: "read-write",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "Target user does not have access to this host",
|
||||
});
|
||||
});
|
||||
|
||||
it("global kill switch overrides an enabled per-host toggle", async () => {
|
||||
state.globalSharingEnabled = false;
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "Session sharing is disabled for this host",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when the per-host toggle is off even though global is on", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 2,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /session-sharing/resolve/:linkToken", () => {
|
||||
async function createActiveLinkShare(
|
||||
overrides: Partial<Record<string, unknown>> = {},
|
||||
) {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()];
|
||||
return share as { linkToken: string; id: string };
|
||||
}
|
||||
|
||||
it("never includes hostname, ip, username, or hostId in the response body", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.jsonBody as Record<string, unknown>;
|
||||
const serialized = JSON.stringify(body).toLowerCase();
|
||||
|
||||
expect(body).not.toHaveProperty("hostname");
|
||||
expect(body).not.toHaveProperty("ip");
|
||||
expect(body).not.toHaveProperty("username");
|
||||
expect(body).not.toHaveProperty("hostId");
|
||||
expect(body).not.toHaveProperty("hostName");
|
||||
expect(serialized).not.toContain("10.0.0");
|
||||
expect(serialized).not.toContain("hostname");
|
||||
expect(serialized).not.toContain('"ip"');
|
||||
expect(serialized).not.toContain("username");
|
||||
});
|
||||
|
||||
it("returns only protocol/permissionLevel/wsPath(/connectParams) for ssh", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.jsonBody).toEqual({
|
||||
protocol: "ssh",
|
||||
permissionLevel: "read-only",
|
||||
wsPath: `/plugin-ws/ssh-terminal/terminal?shareToken=${encodeURIComponent(share.linkToken)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("mints a fresh join token for guac protocols", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "guac-conn-1",
|
||||
protocol: "vnc",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as {
|
||||
linkToken: string;
|
||||
}[];
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((res.jsonBody as Record<string, unknown>).connectParams).toEqual({
|
||||
token: "join-token:guac-conn-1:true",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown link token", async () => {
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: "does-not-exist" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a revoked link token", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
await invoke("delete", "/:shareId", { params: { shareId: share.id } });
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an expired link token", async () => {
|
||||
state.shares.set("share-expired", {
|
||||
id: "share-expired",
|
||||
hostId: 1,
|
||||
ownerUserId: "user-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "expired-token",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: "2000-01-01T00:00:00.000Z",
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: "expired-token" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("re-checks the global kill switch at resolve time, not just at creation time", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
state.globalSharingEnabled = false;
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /session-sharing/:shareId", () => {
|
||||
it("allows the owner to revoke their own share", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a non-owner, non-admin caller", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
state.currentUserId = "user-2";
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("allows an admin to revoke someone else's share", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
state.currentUserId = "admin-1";
|
||||
state.admins.add("admin-1");
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user