feat: move session sharing and collab rooms into a plugin (B12)

This commit is contained in:
LukeGus committed 2026-09-23 23:42:46 -05:00
1 parent 791d8656a1
commit a276f6898c
207 files changed
+35771 -11072

No files matched your search

@@ -29,8 +29,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
"audit_logs",
"session_recordings",
"api_keys",
"session_shares",
"session_share_participants",
];
beforeEach(() => {
@@ -1,353 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SessionShareRepository } from "../../../database/repositories/session-share-repository.js";
describe("SessionShareRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<SessionShareRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('owner-1', 'alice', 'hash'), ('guest-1', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'owner-1', 'host-one', '10.0.0.1', 22, 'root', 'password'), (2, 'owner-1', 'host-two', '10.0.0.2', 22, 'root', 'password');
`);
return new SessionShareRepository(context, onWrite);
}
const FAR_FUTURE = "2999-01-01T00:00:00.000Z";
const FAR_PAST = "2000-01-01T00:00:00.000Z";
it("creates a share and finds it by id", async () => {
const repo = await createRepository();
const created = await repo.create({
id: "share-1",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "link",
linkToken: "token-abc",
permissionLevel: "read-only",
expiresAt: FAR_FUTURE,
});
expect(created).toMatchObject({
id: "share-1",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
linkToken: "token-abc",
permissionLevel: "read-only",
});
const found = await repo.findById("share-1");
expect(found).toMatchObject({ id: "share-1", sessionId: "session-1" });
});
it("findByLinkToken excludes revoked shares", async () => {
const repo = await createRepository();
await repo.create({
id: "share-revoked",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "link",
linkToken: "token-revoked",
permissionLevel: "read-only",
expiresAt: FAR_FUTURE,
});
expect(await repo.findByLinkToken("token-revoked")).not.toBeNull();
await repo.revoke("share-revoked", "owner-1");
expect(await repo.findByLinkToken("token-revoked")).toBeNull();
});
it("findByLinkToken excludes expired shares", async () => {
const repo = await createRepository();
await repo.create({
id: "share-expired",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "link",
linkToken: "token-expired",
permissionLevel: "read-only",
expiresAt: FAR_PAST,
});
expect(await repo.findByLinkToken("token-expired")).toBeNull();
});
it("findByLinkToken returns active, non-expired, non-revoked shares", async () => {
const repo = await createRepository();
await repo.create({
id: "share-active",
hostId: 1,
ownerUserId: "owner-1",
protocol: "vnc",
sessionId: "guac-session-1",
shareType: "link",
linkToken: "token-active",
permissionLevel: "read-write",
expiresAt: FAR_FUTURE,
});
const found = await repo.findByLinkToken("token-active");
expect(found).toMatchObject({
id: "share-active",
protocol: "vnc",
permissionLevel: "read-write",
});
});
it("findSharesTargetingUser returns only active user-targeted shares with host/owner metadata", async () => {
const repo = await createRepository();
await repo.create({
id: "share-user-active",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "user",
targetUserId: "guest-1",
permissionLevel: "read-write",
expiresAt: FAR_FUTURE,
});
// Expired user share for the same target - must be excluded
await repo.create({
id: "share-user-expired",
hostId: 2,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-2",
shareType: "user",
targetUserId: "guest-1",
permissionLevel: "read-only",
expiresAt: FAR_PAST,
});
// Link share, not targeting a user - must be excluded even though it's active
await repo.create({
id: "share-link-active",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-3",
shareType: "link",
linkToken: "token-unrelated",
permissionLevel: "read-only",
expiresAt: FAR_FUTURE,
});
const shares = await repo.findSharesTargetingUser("guest-1");
expect(shares).toHaveLength(1);
expect(shares[0]).toMatchObject({
id: "share-user-active",
hostName: "host-one",
ownerUsername: "alice",
});
});
it("revoke only affects the requesting owner's own share", async () => {
const repo = await createRepository();
await repo.create({
id: "share-owned",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "link",
linkToken: "token-owned",
permissionLevel: "read-only",
expiresAt: FAR_FUTURE,
});
expect(await repo.revoke("share-owned", "guest-1")).toBe(false);
expect(await repo.revoke("share-owned", "owner-1")).toBe(true);
});
it("revokeAsAdmin revokes regardless of owner", async () => {
const repo = await createRepository();
await repo.create({
id: "share-admin-target",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "link",
linkToken: "token-admin",
permissionLevel: "read-only",
expiresAt: FAR_FUTURE,
});
expect(await repo.revokeAsAdmin("share-admin-target")).toBe(true);
expect(await repo.findByLinkToken("token-admin")).toBeNull();
});
it("deleteExpiredShares removes only expired rows", async () => {
const repo = await createRepository();
await repo.create({
id: "share-old",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "link",
linkToken: "token-old",
permissionLevel: "read-only",
expiresAt: FAR_PAST,
});
await repo.create({
id: "share-current",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-2",
shareType: "link",
linkToken: "token-current",
permissionLevel: "read-only",
expiresAt: FAR_FUTURE,
});
const deletedCount = await repo.deleteExpiredShares();
expect(deletedCount).toBe(1);
expect(await repo.findById("share-old")).toBeNull();
expect(await repo.findById("share-current")).not.toBeNull();
});
it("touchShareUsage increments joinCount and sets lastJoinedAt", async () => {
const repo = await createRepository();
await repo.create({
id: "share-touch",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "link",
linkToken: "token-touch",
permissionLevel: "read-only",
expiresAt: FAR_FUTURE,
});
await repo.touchShareUsage("share-touch", "2026-01-01T00:00:00.000Z");
let row = await repo.findById("share-touch");
expect(row?.joinCount).toBe(1);
expect(row?.lastJoinedAt).toBe("2026-01-01T00:00:00.000Z");
await repo.touchShareUsage("share-touch", "2026-01-02T00:00:00.000Z");
row = await repo.findById("share-touch");
expect(row?.joinCount).toBe(2);
});
it("records and closes participant joins", async () => {
const repo = await createRepository();
await repo.create({
id: "share-participants",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "link",
linkToken: "token-participants",
permissionLevel: "read-write",
expiresAt: FAR_FUTURE,
});
const participant = await repo.recordParticipantJoin(
"share-participants",
null,
"Guest",
);
expect(participant).toMatchObject({
shareId: "share-participants",
userId: null,
guestLabel: "Guest",
});
expect(participant.leftAt).toBeNull();
await repo.recordParticipantLeave(participant.id);
});
it("write hook fires on mutating operations", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create({
id: "share-write-hook",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "link",
linkToken: "token-write-hook",
permissionLevel: "read-only",
expiresAt: FAR_FUTURE,
});
expect(writeCount).toBe(1);
await repo.revoke("share-write-hook", "owner-1");
expect(writeCount).toBe(2);
});
it("deleteSharesForHost removes all shares for a host", async () => {
const repo = await createRepository();
await repo.create({
id: "share-host-1a",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-1",
shareType: "link",
linkToken: "token-h1a",
permissionLevel: "read-only",
expiresAt: FAR_FUTURE,
});
await repo.create({
id: "share-host-1b",
hostId: 1,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-2",
shareType: "link",
linkToken: "token-h1b",
permissionLevel: "read-only",
expiresAt: FAR_FUTURE,
});
await repo.create({
id: "share-host-2",
hostId: 2,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "session-3",
shareType: "link",
linkToken: "token-h2",
permissionLevel: "read-only",
expiresAt: FAR_FUTURE,
});
expect(await repo.deleteSharesForHost(1)).toBe(2);
expect(await repo.findById("share-host-2")).not.toBeNull();
});
});
@@ -204,7 +204,7 @@ describe("plugins route", () => {
id: "guest-plugin",
manifestJson: JSON.stringify({
capabilities: ["hosts:read"],
contributes: { guest: true },
contributes: { guest: true, guestViews: ["shared"] },
}),
}),
);
@@ -225,7 +225,10 @@ describe("plugins route", () => {
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
"guest-plugin",
]);
expect(body[0].contributes).toEqual({ guest: true });
expect(body[0].contributes).toEqual({
guest: true,
guestViews: ["shared"],
});
expect(body[0]).not.toHaveProperty("capabilities");
expect(body[0]).not.toHaveProperty("lastError");
});
@@ -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);
});
});
@@ -0,0 +1,84 @@
/**
* ctx.services with named providers: a plugin may only register the names
* its manifest lists, a consumer reaches one provider by name, and each is
* disposed with the plugin that provided it.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: async () => {} }));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import {
clearServiceRegistry,
getRegistration,
} from "../../plugins/service-registry.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function contextFor(pluginId: string, names?: string[]) {
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
provides: [
{
service: "sample.live",
version: "1.0.0",
permission: `${pluginId}.use`,
...(names ? { names } : {}),
},
],
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return { ctx: createPluginContext(manifest, handle), handle };
}
afterEach(() => clearServiceRegistry());
describe("ctx.services named providers", () => {
it("registers a declared name and lists it", () => {
const { ctx } = contextFor("terminal-like", ["ssh"]);
ctx.services.provide("sample.live", { get: () => 1 }, { name: "ssh" });
expect(getRegistration("sample.live", "ssh")?.pluginId).toBe(
"terminal-like",
);
expect(ctx.services.providers("sample.live")).toEqual(["ssh"]);
});
it("refuses a name the manifest does not list", () => {
const { ctx } = contextFor("terminal-like", ["ssh"]);
expect(() =>
ctx.services.provide("sample.live", {}, { name: "rdp" }),
).toThrow(/provides\[\]\.names does not list it/);
expect(() => ctx.services.provide("sample.live", {})).toThrow(
/does not list it/,
);
});
it("refuses a name when the manifest declares an unnamed service", () => {
const { ctx } = contextFor("plain");
expect(() =>
ctx.services.provide("sample.live", {}, { name: "ssh" }),
).toThrow(/does not list it/);
});
it("reaches one provider by name through a handle", () => {
const { ctx } = contextFor("terminal-like", ["ssh"]);
ctx.services.provide("sample.live", { get: () => 1 }, { name: "ssh" });
const named = ctx.services.get<{ get: () => Promise<number> }>(
"sample.live",
{ provider: "ssh" },
);
const other = ctx.services.get("sample.live", { provider: "rdp" });
expect("get" in named).toBe(true);
expect("get" in other).toBe(false);
});
});
@@ -589,6 +589,19 @@ describe("panels, dashboard cards and guest pages", () => {
expect(errors).toMatch(/dashboardCards\[0\]\.titleKey/);
});
it("accepts guest views on a guest plugin and refuses them otherwise", () => {
expect(
validateManifest(
base({ contributes: { guest: true, guestViews: ["shared"] } }),
),
).toEqual([]);
const errors = validateManifest(
base({ contributes: { guestViews: ["Shared View"] } }),
).join();
expect(errors).toMatch(/contributes\.guestViews entries must be/);
expect(errors).toMatch(/needs "contributes\.guest"/);
});
it("rejects an unknown field inside a view and a non-boolean guest", () => {
const errors = validateManifest(
base({
@@ -602,3 +615,41 @@ describe("panels, dashboard cards and guest pages", () => {
expect(errors).toMatch(/contributes\.guest" must be a boolean/);
});
});
describe("named service providers", () => {
const provides = (names: unknown) => [
{
service: "sample.live",
version: "1.0.0",
permission: "sample-plugin.use",
names,
},
];
const permissions = {
permissions: [
{
name: "use",
titleKey: "permissions.use.title",
descriptionKey: "permissions.use.description",
},
],
};
it("accepts a list of provider names", () => {
expect(
validateManifest(
base({ provides: provides(["ssh", "rdp"]), contributes: permissions }),
),
).toEqual([]);
});
it("refuses an empty, duplicated or malformed list", () => {
for (const names of [[], ["ssh", "ssh"], ["SSH"], "ssh"]) {
expect(
validateManifest(
base({ provides: provides(names), contributes: permissions }),
).join(),
).toMatch(/provides\[0\]\.names/);
}
});
});
@@ -22,6 +22,7 @@ const {
createServiceHandle,
getRegistration,
getServiceImplementation,
listProviderNames,
listServices,
provideService,
resolveRequirements,
@@ -99,6 +100,94 @@ describe("service registry", () => {
).toBeUndefined();
});
describe("named providers", () => {
it("keeps several providers of one service side by side", () => {
const ssh = register({
name: "ssh",
implementation: { kind: async () => "ssh" },
});
const rdp = register({
name: "rdp",
pluginId: "other-plugin",
implementation: { kind: async () => "rdp" },
});
expect(listProviderNames("testplugin.greet").sort()).toEqual([
"rdp",
"ssh",
]);
expect(getRegistration("testplugin.greet", "ssh")).toBe(ssh);
expect(getRegistration("testplugin.greet", "rdp")).toBe(rdp);
expect(getRegistration("testplugin.greet")).toBeUndefined();
expect(listServices()).toHaveLength(2);
});
it("hands core a named implementation only by its name", () => {
const ssh = register({ name: "ssh" });
expect(
getServiceImplementation("testplugin.greet", "^1.0.0", "ssh"),
).toBe(ssh.implementation);
expect(
getServiceImplementation("testplugin.greet", "^1.0.0"),
).toBeUndefined();
});
it("revokes one provider and leaves the other", () => {
const ssh = register({ name: "ssh" });
const rdp = register({ name: "rdp" });
expect(revokeService("testplugin.greet", ssh)).toBe(true);
expect(getRegistration("testplugin.greet", "ssh")).toBeUndefined();
expect(getRegistration("testplugin.greet", "rdp")).toBe(rdp);
});
it("routes a named handle to its provider and fails typed once it goes", async () => {
const ssh = register({
name: "ssh",
implementation: { hello: async () => "from ssh" },
});
register({
name: "rdp",
implementation: { hello: async () => "from rdp" },
});
const context = {
resolveUserId: () => "user-1",
hasPermission: async () => true,
audit: () => {},
};
const handle = createServiceHandle<{ hello: () => Promise<string> }>(
"testplugin.greet",
"consumer-plugin",
context,
"ssh",
);
const unnamed = createServiceHandle<{ hello?: () => Promise<string> }>(
"testplugin.greet",
"consumer-plugin",
context,
);
await expect(handle.hello()).resolves.toBe("from ssh");
expect("hello" in unnamed).toBe(false);
revokeService("testplugin.greet", ssh);
expect(() => handle.hello()).toThrow(PluginServiceUnavailableError);
});
it("satisfies a requirement with any compatible provider", () => {
register({ name: "rdp", version: "2.0.0" });
register({ name: "ssh", version: "1.2.0" });
expect(
resolveRequirements(
manifestRequiring([
{ service: "testplugin.greet", versionRange: "^1.0.0" },
]),
).satisfied,
).toBe(true);
});
});
describe("resolution", () => {
it("is satisfied when the version is in range", () => {
register({ version: "1.2.0" });
@@ -0,0 +1,151 @@
/**
* The session sharing settings migration.
*
* An upgrade must be lossless: an instance with sharing switched off, and a
* host with sharing switched off, must stay off once the plugin reads its own
* settings. Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const blockedHostIds: number[] = [];
const coreSettings = new Map<string, string>();
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => blockedHostIds.map((id) => ({ id })),
}),
}));
const { runSessionSharingSettingsMigration } =
await import("../../utils/crypto-migration/session-sharing-settings-migration.js");
function stored(scopeId: string | null, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === scopeId && entry.key === key,
);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
blockedHostIds.length = 0;
coreSettings.clear();
settingsRows.length = 0;
});
describe("runSessionSharingSettingsMigration", () => {
it("moves a disabled global switch into the admin setting", async () => {
coreSettings.set("session_sharing_globally_enabled", "false");
const result = await runSessionSharingSettingsMigration();
expect(result.movedGlobal).toBe(true);
expect(stored(null, "globallyEnabled")).toBe(false);
expect(settingsRows[0]).toMatchObject({
pluginId: "session-sharing",
scope: "admin",
});
});
it("moves hosts that turned sharing off into host settings", async () => {
blockedHostIds.push(4, 9);
const result = await runSessionSharingSettingsMigration();
expect(result.movedHosts).toBe(2);
expect(stored("4", "allowSessionSharing")).toBe(false);
expect(stored("9", "allowSessionSharing")).toBe(false);
});
it("writes nothing when sharing was on everywhere", async () => {
coreSettings.set("session_sharing_globally_enabled", "true");
const result = await runSessionSharingSettingsMigration();
expect(result).toEqual({ movedGlobal: false, movedHosts: 0, skipped: 0 });
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
coreSettings.set("session_sharing_globally_enabled", "false");
blockedHostIds.push(2);
await runSessionSharingSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runSessionSharingSettingsMigration();
expect(second.movedGlobal).toBe(false);
expect(second.movedHosts).toBe(0);
expect(second.skipped).toBe(2);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
blockedHostIds.push(5);
await pluginSettingsRepository.set(
"session-sharing",
"host",
"5",
"allowSessionSharing",
"true",
);
const result = await runSessionSharingSettingsMigration();
expect(result.skipped).toBe(1);
expect(stored("5", "allowSessionSharing")).toBe(true);
});
});