mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -0,0 +1,69 @@
|
||||
import express from "express";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
db?.close();
|
||||
db = null;
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("session-sharing activate", () => {
|
||||
it("provides sessions.sharing and the guest resolver", async () => {
|
||||
server = await startServer();
|
||||
expect(server.mock.services.get("sessions.sharing")).toBeDefined();
|
||||
expect(typeof server.guests.resolve).toBe("function");
|
||||
expect(server.mock.httpRouters).toEqual([
|
||||
{ public: ["/resolve/:linkToken", "/guest/:token"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["db:own", "network:serve"])(
|
||||
"fails closed without %s",
|
||||
async (capability) => {
|
||||
db = await createTestDb(pluginDir);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
},
|
||||
);
|
||||
|
||||
it("cannot check a share target's host access without hosts:read", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
db.sqlite.exec(
|
||||
"INSERT INTO users (id, username) VALUES ('alice', 'alice'), ('bob', 'bob'); INSERT INTO ssh_data (id) VALUES (1)",
|
||||
);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== "hosts:read"),
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
actor: "bob",
|
||||
});
|
||||
await activate(mock.ctx);
|
||||
await expect(
|
||||
mock.ctx.asUser("bob", () => mock.ctx.hosts.checkAccess(1, "connect")),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import type { LiveSessionInfo } from "../../src/backend/live.js";
|
||||
import type {
|
||||
SessionGuestsV1,
|
||||
SessionSharingV1,
|
||||
} from "../../src/backend/services.js";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
/** A stand-in for the terminal's "ssh" sessions.live provider. */
|
||||
export function createFakeLive() {
|
||||
const sessions = new Map<string, LiveSessionInfo>();
|
||||
const calls: Array<[string, ...unknown[]]> = [];
|
||||
return {
|
||||
sessions,
|
||||
calls,
|
||||
addSession(id: string, userId: string, hostId = 1) {
|
||||
sessions.set(id, {
|
||||
id,
|
||||
userId,
|
||||
hostId,
|
||||
hostName: `host-${hostId}`,
|
||||
isConnected: true,
|
||||
createdAt: 1,
|
||||
tabInstanceId: `tab-${id}`,
|
||||
});
|
||||
},
|
||||
provider: {
|
||||
getSession: (id: string) => sessions.get(id) ?? null,
|
||||
ownerEndSession: (...args: unknown[]) => {
|
||||
calls.push(["ownerEndSession", ...args]);
|
||||
},
|
||||
disconnectParticipants: (...args: unknown[]) => {
|
||||
calls.push(["disconnectParticipants", ...args]);
|
||||
return 0;
|
||||
},
|
||||
setRoomShareControl: (...args: unknown[]) => {
|
||||
calls.push(["setRoomShareControl", ...args]);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
live: ReturnType<typeof createFakeLive>;
|
||||
sharing: SessionSharingV1;
|
||||
guests: SessionGuestsV1;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string | null; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* the way core mounts it: JSON parsed and the acting user set from the
|
||||
* request (none for a guest), everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
users?: string[];
|
||||
admins?: string[];
|
||||
hostIds?: number[];
|
||||
/** Hosts ctx.hosts.checkAccess grants. Defaults to every host. */
|
||||
accessibleHostIds?: number[];
|
||||
settings?: Record<string, unknown>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["alice", "bob", "carol"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username, is_admin) VALUES (?, ?, ?)")
|
||||
.run(user, user, options.admins?.includes(user) ? 1 : 0);
|
||||
}
|
||||
const hostIds = options.hostIds ?? [1, 2];
|
||||
for (const id of hostIds) {
|
||||
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(id);
|
||||
}
|
||||
|
||||
const live = createFakeLive();
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ["session-sharing.use"],
|
||||
settings: options.settings,
|
||||
hosts: (options.accessibleHostIds ?? hostIds).map((id) => ({
|
||||
id,
|
||||
userId: "alice",
|
||||
name: `host-${id}`,
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: [],
|
||||
folder: null,
|
||||
authType: "password",
|
||||
})) as never,
|
||||
services: { "sessions.live#ssh": live.provider },
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") || undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
live,
|
||||
sharing: mock.services.get("sessions.sharing") as SessionSharingV1,
|
||||
guests: mock.ctx.registry.consume<SessionGuestsV1>(
|
||||
"sessions.sharing.guests",
|
||||
)!,
|
||||
async request(method, path, { user = "alice", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(user ? { "x-test-user": user } : {}),
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
for (const dispose of [...mock.disposals].reverse()) {
|
||||
await Promise.resolve(dispose()).catch(() => {});
|
||||
}
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs a service call the way a consumer's handle does: as that user. */
|
||||
export function asUser<T>(
|
||||
server: TestServer,
|
||||
userId: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return server.mock.ctx.asUser(userId, fn);
|
||||
}
|
||||
|
||||
export function expireShare(server: TestServer, shareId: string): void {
|
||||
server.db.sqlite
|
||||
.prepare("UPDATE p_session_sharing_shares SET expires_at = ? WHERE id = ?")
|
||||
.run(new Date(Date.now() - 60_000).toISOString(), shareId);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
// The four tables as core's SQLite bootstrap created them before 2.9.0,
|
||||
// including the guest token column and its index added later.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE session_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
tab_instance_id TEXT,
|
||||
share_type TEXT NOT NULL,
|
||||
target_user_id TEXT,
|
||||
link_token TEXT UNIQUE,
|
||||
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
last_joined_at TEXT,
|
||||
join_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE session_share_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
guest_label TEXT,
|
||||
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
left_at TEXT,
|
||||
FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE collab_rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
persistent INTEGER NOT NULL DEFAULT 0,
|
||||
presenter_user_id TEXT,
|
||||
stage_protocol TEXT,
|
||||
stage_host_id INTEGER,
|
||||
stage_share_id TEXT,
|
||||
guest_link_token TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (presenter_user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (stage_host_id) REFERENCES ssh_data (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (stage_share_id) REFERENCES session_shares (id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE TABLE collab_room_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
room_role TEXT NOT NULL DEFAULT 'member',
|
||||
added_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (room_id, user_id),
|
||||
FOREIGN KEY (room_id) REFERENCES collab_rooms (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (added_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX idx_session_shares_session_id ON session_shares (session_id);
|
||||
CREATE INDEX idx_session_shares_host_id ON session_shares (host_id);
|
||||
CREATE UNIQUE INDEX idx_collab_rooms_guest_token ON collab_rooms (guest_link_token);
|
||||
`;
|
||||
|
||||
const TABLES = {
|
||||
session_shares: "p_session_sharing_shares",
|
||||
session_share_participants: "p_session_sharing_share_participants",
|
||||
collab_rooms: "p_session_sharing_rooms",
|
||||
collab_room_members: "p_session_sharing_room_members",
|
||||
};
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const exists = (type: "table" | "index", name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = ? AND name = ?")
|
||||
.get(type, name);
|
||||
|
||||
function seedUsersAndHost(sqlite: TestDb["sqlite"]) {
|
||||
sqlite.exec(
|
||||
"INSERT INTO users (id, username) VALUES ('alice', 'alice'), ('bob', 'bob')",
|
||||
);
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
|
||||
}
|
||||
|
||||
describe("adopting the session sharing tables", () => {
|
||||
it("keeps every row and index when the legacy tables are there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
seedUsersAndHost(sqlite);
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite.exec(`
|
||||
INSERT INTO session_shares (id, host_id, owner_user_id, protocol, session_id, share_type, link_token, permission_level, expires_at, join_count)
|
||||
VALUES ('share-1', 7, 'alice', 'ssh', 'sess-1', 'link', 'tok-1', 'read-write', '2999-01-01T00:00:00.000Z', 3);
|
||||
INSERT INTO session_share_participants (share_id, user_id, guest_label) VALUES ('share-1', NULL, 'Guest');
|
||||
INSERT INTO collab_rooms (id, name, owner_user_id, persistent, stage_share_id, stage_host_id, guest_link_token)
|
||||
VALUES ('room-1', 'Standup', 'alice', 1, 'share-1', 7, 'guest-tok');
|
||||
INSERT INTO collab_room_members (room_id, user_id, room_role) VALUES ('room-1', 'alice', 'host'), ('room-1', 'bob', 'member');
|
||||
`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_session_sharing"]);
|
||||
for (const [legacy, adopted] of Object.entries(TABLES)) {
|
||||
expect(exists("table", legacy)).toBe(false);
|
||||
expect(exists("table", adopted)).toBe(true);
|
||||
}
|
||||
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT id, link_token, permission_level, join_count FROM p_session_sharing_shares",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
id: "share-1",
|
||||
link_token: "tok-1",
|
||||
permission_level: "read-write",
|
||||
join_count: 3,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT name, persistent, stage_share_id, guest_link_token FROM p_session_sharing_rooms",
|
||||
)
|
||||
.get(),
|
||||
).toEqual({
|
||||
name: "Standup",
|
||||
persistent: 1,
|
||||
stage_share_id: "share-1",
|
||||
guest_link_token: "guest-tok",
|
||||
});
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT COUNT(*) AS n FROM p_session_sharing_room_members")
|
||||
.get(),
|
||||
).toEqual({ n: 2 });
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT guest_label FROM p_session_sharing_share_participants")
|
||||
.get(),
|
||||
).toEqual({ guest_label: "Guest" });
|
||||
|
||||
for (const index of [
|
||||
"idx_session_shares_session_id",
|
||||
"idx_session_shares_host_id",
|
||||
"idx_collab_rooms_guest_token",
|
||||
"idx_collab_rooms_owner",
|
||||
"idx_collab_room_members_user",
|
||||
]) {
|
||||
expect(exists("index", index)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("follows the rename in the links between the tables", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
seedUsersAndHost(sqlite);
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
},
|
||||
});
|
||||
const ddl = (name: string) =>
|
||||
(
|
||||
db!.sqlite
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE name = ?")
|
||||
.get(name) as { sql: string }
|
||||
).sql;
|
||||
|
||||
expect(ddl("p_session_sharing_rooms")).toContain(
|
||||
'"p_session_sharing_shares"',
|
||||
);
|
||||
expect(ddl("p_session_sharing_room_members")).toContain(
|
||||
'"p_session_sharing_rooms"',
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the tables fresh when there are no legacy ones", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => seedUsersAndHost(sqlite),
|
||||
});
|
||||
|
||||
for (const [legacy, adopted] of Object.entries(TABLES)) {
|
||||
expect(exists("table", legacy)).toBe(false);
|
||||
expect(exists("table", adopted)).toBe(true);
|
||||
}
|
||||
|
||||
db.sqlite.exec(`
|
||||
INSERT INTO p_session_sharing_shares (id, host_id, owner_user_id, protocol, session_id, share_type, expires_at)
|
||||
VALUES ('s', 7, 'alice', 'ssh', 'x', 'room', '2999-01-01');
|
||||
INSERT INTO p_session_sharing_rooms (id, name, owner_user_id, stage_share_id) VALUES ('r', 'R', 'alice', 's');
|
||||
INSERT INTO p_session_sharing_room_members (room_id, user_id) VALUES ('r', 'bob');
|
||||
`);
|
||||
// The hand-kept set-null link: deleting the share clears the stage.
|
||||
db.sqlite.exec("DELETE FROM p_session_sharing_shares WHERE id = 's'");
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT stage_share_id FROM p_session_sharing_rooms")
|
||||
.get(),
|
||||
).toEqual({ stage_share_id: null });
|
||||
// And deleting a user cascades to their memberships.
|
||||
db.sqlite.exec("DELETE FROM users WHERE id = 'bob'");
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT COUNT(*) AS n FROM p_session_sharing_room_members")
|
||||
.get(),
|
||||
).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function roomWithBob(s: TestServer, persistent = false) {
|
||||
const created = await s.request("POST", "/rooms", {
|
||||
body: { name: "Standup", persistent },
|
||||
});
|
||||
const roomId = created.body.room.id as string;
|
||||
await s.request("POST", `/rooms/${roomId}/members`, {
|
||||
body: { userIds: ["bob"] },
|
||||
});
|
||||
return roomId;
|
||||
}
|
||||
|
||||
async function present(s: TestServer, roomId: string, user = "alice") {
|
||||
s.live.addSession(`sess-${user}`, user);
|
||||
return s.request("POST", `/rooms/${roomId}/present`, {
|
||||
user,
|
||||
body: { protocol: "ssh", sessionId: `sess-${user}`, hostId: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("collab rooms", () => {
|
||||
it("creates a room with the creator as host and hides the guest token", async () => {
|
||||
server = await startServer();
|
||||
const res = await server.request("POST", "/rooms", {
|
||||
body: { name: " Standup " },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.room).toMatchObject({
|
||||
name: "Standup",
|
||||
ownerUserId: "alice",
|
||||
guestLinkEnabled: false,
|
||||
});
|
||||
expect(res.body.room).not.toHaveProperty("guestLinkToken");
|
||||
|
||||
const detail = await server.request("GET", `/rooms/${res.body.room.id}`);
|
||||
expect(detail.body.isHost).toBe(true);
|
||||
expect(detail.body.members).toEqual([
|
||||
expect.objectContaining({ userId: "alice", roomRole: "host" }),
|
||||
]);
|
||||
expect(detail.body.eventsWsPath).toBe("/plugin-ws/ssh-terminal/terminal");
|
||||
expect(server.mock.audits.map((entry) => entry.action)).toContain(
|
||||
"collab_room_create",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps rooms private to members and invites to the host", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
|
||||
expect(
|
||||
(await server.request("GET", `/rooms/${roomId}`, { user: "carol" }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/members`, {
|
||||
user: "bob",
|
||||
body: { userIds: ["carol"] },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await server.request("GET", "/rooms", { user: "bob" })).body.rooms,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("expands a role invite to its current members", async () => {
|
||||
server = await startServer();
|
||||
server.db.sqlite.exec(
|
||||
"INSERT INTO roles (id, name) VALUES (5, 'ops'); INSERT INTO user_roles (user_id, role_id) VALUES ('carol', 5)",
|
||||
);
|
||||
const created = await server.request("POST", "/rooms", {
|
||||
body: { name: "Ops" },
|
||||
});
|
||||
const roomId = created.body.room.id;
|
||||
await server.request("POST", `/rooms/${roomId}/members`, {
|
||||
body: { roleIds: [5] },
|
||||
});
|
||||
expect(
|
||||
(await server.request("GET", `/rooms/${roomId}`, { user: "carol" }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/members`, {
|
||||
body: { roleIds: [99] },
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("puts a member's live session on stage as a read-only room share", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
const res = await present(server, roomId, "bob");
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const stage = await server.request("GET", `/rooms/${roomId}/stage`);
|
||||
expect(stage.body.stage).toMatchObject({
|
||||
presenterUserId: "bob",
|
||||
protocol: "ssh",
|
||||
sessionId: "sess-bob",
|
||||
});
|
||||
const row = server.db.sqlite
|
||||
.prepare(
|
||||
"SELECT share_type, permission_level FROM p_session_sharing_shares",
|
||||
)
|
||||
.get();
|
||||
expect(row).toEqual({ share_type: "room", permission_level: "read-only" });
|
||||
});
|
||||
|
||||
it("refuses to present someone else's session", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
server.live.addSession("sess-alice", "alice");
|
||||
const res = await server.request("POST", `/rooms/${roomId}/present`, {
|
||||
user: "bob",
|
||||
body: { protocol: "ssh", sessionId: "sess-alice", hostId: 1 },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("clears a stage whose session died", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
await present(server, roomId);
|
||||
server.live.sessions.delete("sess-alice");
|
||||
|
||||
const stage = await server.request("GET", `/rooms/${roomId}/stage`, {
|
||||
user: "bob",
|
||||
});
|
||||
expect(stage.body.stage).toBeNull();
|
||||
const detail = await server.request("GET", `/rooms/${roomId}`);
|
||||
expect(detail.body.stage.shareId).toBeNull();
|
||||
});
|
||||
|
||||
it("hands control only from the presenter or host, and only to members", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
await present(server, roomId);
|
||||
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/control`, {
|
||||
user: "bob",
|
||||
body: { userId: "bob" },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/control`, {
|
||||
body: { userId: "carol" },
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
|
||||
const granted = await server.request("POST", `/rooms/${roomId}/control`, {
|
||||
body: { userId: "bob" },
|
||||
});
|
||||
expect(granted.body).toEqual({ controllerUserId: "bob" });
|
||||
expect(server.live.calls).toContainEqual([
|
||||
"setRoomShareControl",
|
||||
"sess-alice",
|
||||
expect.any(String),
|
||||
"bob",
|
||||
]);
|
||||
|
||||
// The controller may give it back.
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/control`, {
|
||||
user: "bob",
|
||||
body: { userId: null },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it("rate limits hand raises", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
await present(server, roomId);
|
||||
|
||||
const first = await server.request(
|
||||
"POST",
|
||||
`/rooms/${roomId}/control/request`,
|
||||
{ user: "bob" },
|
||||
);
|
||||
expect(first.body.request).toMatchObject({
|
||||
userId: "bob",
|
||||
username: "bob",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/control/request`, {
|
||||
user: "bob",
|
||||
})
|
||||
).status,
|
||||
).toBe(429);
|
||||
expect(
|
||||
(
|
||||
await server.request("GET", `/rooms/${roomId}/control/requests`, {
|
||||
user: "bob",
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await server.request("GET", `/rooms/${roomId}/control/requests`)).body
|
||||
.requests,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("removes a member and drops them from the stage", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
await present(server, roomId);
|
||||
|
||||
expect(
|
||||
(await server.request("DELETE", `/rooms/${roomId}/members/alice`)).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(await server.request("DELETE", `/rooms/${roomId}/members/bob`)).status,
|
||||
).toBe(200);
|
||||
expect(server.live.calls).toContainEqual([
|
||||
"disconnectParticipants",
|
||||
"sess-alice",
|
||||
expect.any(String),
|
||||
{
|
||||
userId: "bob",
|
||||
reason: "You were removed from the collaboration room",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends a one-off room for good and keeps a persistent one", async () => {
|
||||
server = await startServer();
|
||||
const oneOff = await roomWithBob(server);
|
||||
await present(server, oneOff);
|
||||
expect(
|
||||
(await server.request("POST", `/rooms/${oneOff}/end`, { user: "bob" }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
await server.request("POST", `/rooms/${oneOff}/end`);
|
||||
expect((await server.request("GET", `/rooms/${oneOff}`)).status).toBe(404);
|
||||
|
||||
const persistent = await roomWithBob(server, true);
|
||||
await server.request("POST", `/rooms/${persistent}/end`);
|
||||
expect((await server.request("GET", `/rooms/${persistent}`)).status).toBe(
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes a room and its members", async () => {
|
||||
server = await startServer();
|
||||
const roomId = await roomWithBob(server);
|
||||
expect((await server.request("DELETE", `/rooms/${roomId}`)).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect(
|
||||
server.db.sqlite
|
||||
.prepare("SELECT COUNT(*) AS n FROM p_session_sharing_room_members")
|
||||
.get(),
|
||||
).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Guest tokens, participant permissions and expiry. These are what keep a
|
||||
* share link from becoming a way into someone else's session, so each rule
|
||||
* the old core code enforced has a test here.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { asUser, expireShare, startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function createShare(
|
||||
s: TestServer,
|
||||
body: Record<string, unknown> = {},
|
||||
): Promise<{ shareId: string; linkToken: string | null }> {
|
||||
if (!s.live.sessions.has("sess-1")) s.live.addSession("sess-1", "alice");
|
||||
const res = await s.request("POST", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "sess-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
...body,
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return res.body;
|
||||
}
|
||||
|
||||
async function roomOnStage(s: TestServer) {
|
||||
const created = await s.request("POST", "/rooms", { body: { name: "R" } });
|
||||
const roomId = created.body.room.id as string;
|
||||
await s.request("POST", `/rooms/${roomId}/members`, {
|
||||
body: { userIds: ["bob"] },
|
||||
});
|
||||
s.live.addSession("sess-stage", "alice");
|
||||
await s.request("POST", `/rooms/${roomId}/present`, {
|
||||
body: { protocol: "ssh", sessionId: "sess-stage", hostId: 1 },
|
||||
});
|
||||
const link = await s.request("POST", `/rooms/${roomId}/guest-link`, {
|
||||
body: { enabled: true },
|
||||
});
|
||||
return { roomId, token: link.body.guestLinkToken as string };
|
||||
}
|
||||
|
||||
describe("share link guests", () => {
|
||||
it("resolves a live link without any host details", async () => {
|
||||
server = await startServer();
|
||||
const { linkToken } = await createShare(server, {
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
const res = await server.request("GET", `/resolve/${linkToken}`, {
|
||||
user: null,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
protocol: "ssh",
|
||||
permissionLevel: "read-write",
|
||||
wsPath: `/plugin-ws/ssh-terminal/terminal?shareToken=${linkToken}`,
|
||||
});
|
||||
|
||||
const joined = await server.guests.resolve({
|
||||
shareToken: linkToken!,
|
||||
clientIp: "1.1.1.1",
|
||||
});
|
||||
expect(joined).toEqual({
|
||||
ok: true,
|
||||
share: expect.objectContaining({
|
||||
sessionId: "sess-1",
|
||||
permissionLevel: "read-write",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a read-only link read-only", async () => {
|
||||
server = await startServer();
|
||||
const { linkToken } = await createShare(server);
|
||||
const joined = await server.guests.resolve({
|
||||
shareToken: linkToken!,
|
||||
clientIp: "1.1.1.1",
|
||||
});
|
||||
expect(joined).toMatchObject({ share: { permissionLevel: "read-only" } });
|
||||
});
|
||||
|
||||
it("refuses an unknown or guessed token", async () => {
|
||||
server = await startServer();
|
||||
await createShare(server);
|
||||
for (const token of ["nope", "", "%00"]) {
|
||||
const res = await server.request("GET", `/resolve/${token || "x"}`, {
|
||||
user: null,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
expect(
|
||||
await server.guests.resolve({ shareToken: token, clientIp: "1.1.1.1" }),
|
||||
).toMatchObject({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a revoked link", async () => {
|
||||
server = await startServer();
|
||||
const { shareId, linkToken } = await createShare(server);
|
||||
await server.request("DELETE", `/${shareId}`);
|
||||
|
||||
expect(
|
||||
(await server.request("GET", `/resolve/${linkToken}`, { user: null }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
shareToken: linkToken!,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "Invalid or expired share link" });
|
||||
});
|
||||
|
||||
it("refuses a link once sharing is switched off", async () => {
|
||||
server = await startServer();
|
||||
const { linkToken } = await createShare(server);
|
||||
await server.mock.ctx.settings.set("globallyEnabled", false);
|
||||
|
||||
expect(
|
||||
(await server.request("GET", `/resolve/${linkToken}`, { user: null }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
shareToken: linkToken!,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("answers 404 once the session is gone", async () => {
|
||||
server = await startServer();
|
||||
const { linkToken } = await createShare(server);
|
||||
server.live.sessions.delete("sess-1");
|
||||
const res = await server.request("GET", `/resolve/${linkToken}`, {
|
||||
user: null,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rate limits resolves per IP", async () => {
|
||||
server = await startServer();
|
||||
let last = 0;
|
||||
for (let i = 0; i < 31; i++) {
|
||||
last = (await server.request("GET", "/resolve/guess", { user: null }))
|
||||
.status;
|
||||
}
|
||||
expect(last).toBe(429);
|
||||
});
|
||||
|
||||
it("records a guest join against the share", async () => {
|
||||
server = await startServer();
|
||||
const { shareId } = await createShare(server);
|
||||
await server.guests.recordJoin(shareId);
|
||||
expect(
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
"SELECT join_count, (SELECT guest_label FROM p_session_sharing_share_participants) AS label FROM p_session_sharing_shares",
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ join_count: 1, label: "Guest" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("expired shares", () => {
|
||||
it("stops resolving a link once it expires", async () => {
|
||||
server = await startServer();
|
||||
const { shareId, linkToken } = await createShare(server);
|
||||
expireShare(server, shareId);
|
||||
|
||||
expect(
|
||||
(await server.request("GET", `/resolve/${linkToken}`, { user: null }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
shareToken: linkToken!,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toMatchObject({ ok: false });
|
||||
expect((await server.request("GET", "/host/1/active")).body.shares).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a member join on an expired user share", async () => {
|
||||
server = await startServer();
|
||||
const { shareId } = await createShare(server, {
|
||||
shareType: "user",
|
||||
targetUserId: "bob",
|
||||
});
|
||||
expireShare(server, shareId);
|
||||
expect(
|
||||
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("clears a room stage whose share expired and stops guests", async () => {
|
||||
server = await startServer();
|
||||
const { roomId, token } = await roomOnStage(server);
|
||||
const shareId = (await server.request("GET", `/rooms/${roomId}`)).body.stage
|
||||
.shareId;
|
||||
expireShare(server, shareId);
|
||||
|
||||
const guest = await server.request("GET", `/guest/${token}`, {
|
||||
user: null,
|
||||
});
|
||||
expect(guest.body).toEqual({ roomName: "R", stage: null });
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
roomGuestToken: token,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "Nothing is being presented" });
|
||||
expect(
|
||||
(await server.request("GET", `/rooms/${roomId}/stage`)).body.stage,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("member joins", () => {
|
||||
it("lets only the target of a user share join", async () => {
|
||||
server = await startServer();
|
||||
const { shareId } = await createShare(server, {
|
||||
shareType: "user",
|
||||
targetUserId: "bob",
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
expect(
|
||||
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
|
||||
).toEqual({
|
||||
share: {
|
||||
id: shareId,
|
||||
sessionId: "sess-1",
|
||||
permissionLevel: "read-write",
|
||||
},
|
||||
displayName: "bob",
|
||||
});
|
||||
expect(
|
||||
await asUser(server, "carol", () =>
|
||||
server!.sharing.authorizeJoin(shareId),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses the target once they lose access to the host", async () => {
|
||||
server = await startServer({ accessibleHostIds: [2] });
|
||||
server.live.addSession("sess-1", "alice", 1);
|
||||
// Inserted directly: bob could reach host 1 when it was shared with him.
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_session_sharing_shares (id, host_id, owner_user_id, protocol, session_id, share_type, target_user_id, expires_at) VALUES ('s', 1, 'alice', 'ssh', 'sess-1', 'user', 'bob', ?)",
|
||||
)
|
||||
.run(new Date(Date.now() + 60_000).toISOString());
|
||||
|
||||
expect(
|
||||
await asUser(server, "bob", () => server!.sharing.authorizeJoin("s")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a link share joined as a member, and no user at all", async () => {
|
||||
server = await startServer();
|
||||
const { shareId } = await createShare(server);
|
||||
expect(
|
||||
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
|
||||
).toBeNull();
|
||||
expect(await server.sharing.authorizeJoin(shareId)).toBeNull();
|
||||
});
|
||||
|
||||
it("lets room members, and only them, join a room stage read-only", async () => {
|
||||
server = await startServer();
|
||||
const { roomId } = await roomOnStage(server);
|
||||
const shareId = (await server.request("GET", `/rooms/${roomId}`)).body.stage
|
||||
.shareId;
|
||||
|
||||
expect(
|
||||
await asUser(server, "bob", () => server!.sharing.authorizeJoin(shareId)),
|
||||
).toMatchObject({ share: { permissionLevel: "read-only" } });
|
||||
expect(
|
||||
await asUser(server, "carol", () =>
|
||||
server!.sharing.authorizeJoin(shareId),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("subscribes only members to room events", async () => {
|
||||
server = await startServer();
|
||||
const { roomId } = await roomOnStage(server);
|
||||
const socket = { readyState: 1, OPEN: 1, send: () => {} };
|
||||
expect(
|
||||
await asUser(server, "carol", () =>
|
||||
server!.sharing.subscribeRoom(roomId, socket),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await asUser(server, "bob", () =>
|
||||
server!.sharing.subscribeRoom(roomId, socket),
|
||||
),
|
||||
).toBe(true);
|
||||
await server.sharing.unsubscribeRoom(socket);
|
||||
});
|
||||
});
|
||||
|
||||
describe("room guest links", () => {
|
||||
it("resolves the stage for a guest without host details", async () => {
|
||||
server = await startServer();
|
||||
const { token } = await roomOnStage(server);
|
||||
|
||||
const res = await server.request("GET", `/guest/${token}`, { user: null });
|
||||
expect(res.body).toEqual({
|
||||
roomName: "R",
|
||||
stage: {
|
||||
protocol: "ssh",
|
||||
shareId: expect.any(String),
|
||||
wsPath: `/plugin-ws/ssh-terminal/terminal?roomGuestToken=${token}`,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
roomGuestToken: token,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toMatchObject({ ok: true, share: { permissionLevel: "read-only" } });
|
||||
});
|
||||
|
||||
it("refuses the old token after a rotate and any token after disable", async () => {
|
||||
server = await startServer();
|
||||
const { roomId, token } = await roomOnStage(server);
|
||||
|
||||
const rotated = await server.request(
|
||||
"POST",
|
||||
`/rooms/${roomId}/guest-link`,
|
||||
{
|
||||
body: { enabled: true },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(await server.request("GET", `/guest/${token}`, { user: null })).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
await server.guests.resolve({
|
||||
roomGuestToken: token,
|
||||
clientIp: "1.1.1.1",
|
||||
}),
|
||||
).toMatchObject({ ok: false });
|
||||
expect(server.live.calls).toContainEqual([
|
||||
"disconnectParticipants",
|
||||
"sess-stage",
|
||||
expect.any(String),
|
||||
{ userId: null, reason: "The guest link was rotated" },
|
||||
]);
|
||||
|
||||
await server.request("POST", `/rooms/${roomId}/guest-link`, {
|
||||
body: { enabled: false },
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await server.request("GET", `/guest/${rotated.body.guestLinkToken}`, {
|
||||
user: null,
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses a guest link after the room ends", async () => {
|
||||
server = await startServer();
|
||||
const { roomId, token } = await roomOnStage(server);
|
||||
await server.request("POST", `/rooms/${roomId}/end`);
|
||||
expect(
|
||||
(await server.request("GET", `/guest/${token}`, { user: null })).status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("does not accept a room guest token as a share link, or the reverse", async () => {
|
||||
server = await startServer();
|
||||
const { token } = await roomOnStage(server);
|
||||
const { linkToken } = await createShare(server);
|
||||
|
||||
expect(
|
||||
await server.guests.resolve({ shareToken: token, clientIp: "1.1.1.1" }),
|
||||
).toMatchObject({ ok: false });
|
||||
expect(
|
||||
(await server.request("GET", `/guest/${linkToken}`, { user: null }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it("only lets the host manage the guest link", async () => {
|
||||
server = await startServer();
|
||||
const { roomId } = await roomOnStage(server);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", `/rooms/${roomId}/guest-link`, {
|
||||
user: "bob",
|
||||
body: { enabled: true },
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it("rate limits room guests per IP", async () => {
|
||||
server = await startServer();
|
||||
let refused = false;
|
||||
for (let i = 0; i < 61; i++) {
|
||||
const result = await server.guests.resolve({
|
||||
roomGuestToken: "guess",
|
||||
clientIp: "9.9.9.9",
|
||||
});
|
||||
if (!result.ok && result.reason === "Too many requests") refused = true;
|
||||
}
|
||||
expect(refused).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
const linkShare = (overrides: Record<string, unknown> = {}) => ({
|
||||
hostId: 1,
|
||||
sessionId: "sess-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("share routes", () => {
|
||||
it("creates a link share for a session the caller owns", async () => {
|
||||
server = await startServer();
|
||||
server.live.addSession("sess-1", "alice");
|
||||
|
||||
const res = await server.request("POST", "/create", { body: linkShare() });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.linkToken).toMatch(/^[A-Za-z0-9_-]{32}$/);
|
||||
const active = await server.request("GET", "/host/1/active");
|
||||
expect(active.body.shares).toHaveLength(1);
|
||||
expect(active.body.shares[0]).toMatchObject({
|
||||
ownerUserId: "alice",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a session someone else owns", async () => {
|
||||
server = await startServer();
|
||||
server.live.addSession("sess-1", "bob");
|
||||
|
||||
const res = await server.request("POST", "/create", { body: linkShare() });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses when sharing is off for the host or everywhere", async () => {
|
||||
server = await startServer({ settings: { globallyEnabled: false } });
|
||||
server.live.addSession("sess-1", "alice");
|
||||
expect(
|
||||
(await server.request("POST", "/create", { body: linkShare() })).status,
|
||||
).toBe(403);
|
||||
await server.close();
|
||||
|
||||
server = await startServer();
|
||||
server.live.addSession("sess-1", "alice");
|
||||
await server.mock.ctx.settings.setHost(1, "allowSessionSharing", false);
|
||||
expect(
|
||||
(await server.request("POST", "/create", { body: linkShare() })).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it("validates the request", async () => {
|
||||
server = await startServer();
|
||||
for (const body of [
|
||||
{},
|
||||
linkShare({ protocol: "ftp" }),
|
||||
linkShare({ shareType: "room" }),
|
||||
linkShare({ permissionLevel: "owner" }),
|
||||
linkShare({ shareType: "user" }),
|
||||
]) {
|
||||
expect((await server.request("POST", "/create", { body })).status).toBe(
|
||||
400,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("shares with a user only when they can reach the host", async () => {
|
||||
server = await startServer({ accessibleHostIds: [1] });
|
||||
server.live.addSession("sess-1", "alice", 1);
|
||||
server.live.addSession("sess-2", "alice", 2);
|
||||
|
||||
const ok = await server.request("POST", "/create", {
|
||||
body: linkShare({ shareType: "user", targetUserId: "bob" }),
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.linkToken).toBeNull();
|
||||
|
||||
const noAccess = await server.request("POST", "/create", {
|
||||
body: linkShare({
|
||||
hostId: 2,
|
||||
sessionId: "sess-2",
|
||||
shareType: "user",
|
||||
targetUserId: "bob",
|
||||
}),
|
||||
});
|
||||
expect(noAccess.status).toBe(403);
|
||||
|
||||
const unknownUser = await server.request("POST", "/create", {
|
||||
body: linkShare({ shareType: "user", targetUserId: "nobody" }),
|
||||
});
|
||||
expect(unknownUser.status).toBe(403);
|
||||
});
|
||||
|
||||
it("lets the owner or an admin revoke, and kicks SSH guests", async () => {
|
||||
server = await startServer({ admins: ["carol"] });
|
||||
server.live.addSession("sess-1", "alice");
|
||||
const { body } = await server.request("POST", "/create", {
|
||||
body: linkShare(),
|
||||
});
|
||||
|
||||
expect(
|
||||
(await server.request("DELETE", `/${body.shareId}`, { user: "bob" }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await server.request("DELETE", `/${body.shareId}`, { user: "carol" }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
expect(server.live.calls[0]).toEqual([
|
||||
"ownerEndSession",
|
||||
"sess-1",
|
||||
"Session share revoked by owner",
|
||||
]);
|
||||
expect(
|
||||
(
|
||||
await server.request("GET", `/resolve/${body.linkToken}`, {
|
||||
user: null,
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
expect((await server.request("DELETE", "/missing")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("ends a shared session for its owner only", async () => {
|
||||
server = await startServer();
|
||||
server.live.addSession("sess-1", "alice");
|
||||
const { body } = await server.request("POST", "/create", {
|
||||
body: linkShare(),
|
||||
});
|
||||
|
||||
expect(
|
||||
(await server.request("POST", `/${body.shareId}/end`, { user: "bob" }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect((await server.request("POST", `/${body.shareId}/end`)).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect(server.live.calls).toContainEqual([
|
||||
"ownerEndSession",
|
||||
"sess-1",
|
||||
"Session ended by owner",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists live sessions shared with the caller", async () => {
|
||||
server = await startServer();
|
||||
server.live.addSession("sess-1", "alice");
|
||||
const { body } = await server.request("POST", "/create", {
|
||||
body: linkShare({ shareType: "user", targetUserId: "bob" }),
|
||||
});
|
||||
|
||||
const res = await server.request("GET", "/shared-with-me", { user: "bob" });
|
||||
expect(res.body).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: "sess-1",
|
||||
hostName: "host-1",
|
||||
isOwnSession: false,
|
||||
sharedByUsername: "alice",
|
||||
shareId: body.shareId,
|
||||
permissionLevel: "read-only",
|
||||
}),
|
||||
]);
|
||||
|
||||
server.live.sessions.delete("sess-1");
|
||||
expect(
|
||||
(await server.request("GET", "/shared-with-me", { user: "bob" })).body,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists users and roles for the pickers", async () => {
|
||||
server = await startServer();
|
||||
server.db.sqlite
|
||||
.prepare("INSERT INTO roles (id, name, display_name) VALUES (?, ?, ?)")
|
||||
.run(3, "ops", "Operations");
|
||||
const res = await server.request("GET", "/directory");
|
||||
expect(res.body.users.map((u: { id: string }) => u.id)).toEqual([
|
||||
"alice",
|
||||
"bob",
|
||||
"carol",
|
||||
]);
|
||||
expect(res.body.roles).toEqual([
|
||||
{ id: 3, name: "ops", displayName: "Operations" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("denies every signed-in route without the use permission", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
for (const [method, path] of [
|
||||
["POST", "/create"],
|
||||
["GET", "/host/1/active"],
|
||||
["GET", "/shared-with-me"],
|
||||
["GET", "/directory"],
|
||||
["DELETE", "/share-1"],
|
||||
["POST", "/share-1/end"],
|
||||
["POST", "/rooms"],
|
||||
["GET", "/rooms"],
|
||||
["GET", "/rooms/r"],
|
||||
["POST", "/rooms/r/members"],
|
||||
["DELETE", "/rooms/r/members/bob"],
|
||||
["POST", "/rooms/r/present"],
|
||||
["POST", "/rooms/r/stop"],
|
||||
["GET", "/rooms/r/stage"],
|
||||
["POST", "/rooms/r/control"],
|
||||
["POST", "/rooms/r/control/request"],
|
||||
["GET", "/rooms/r/control/requests"],
|
||||
["DELETE", "/rooms/r/control/requests/bob"],
|
||||
["POST", "/rooms/r/guest-link"],
|
||||
["POST", "/rooms/r/end"],
|
||||
["DELETE", "/rooms/r"],
|
||||
]) {
|
||||
const res = await server.request(method, path, { body: {} });
|
||||
expect([method, path, res.status]).toEqual([method, path, 403]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ComponentType } from "react";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { PluginComponent } from "@termix/plugin-sdk/ui";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import SharedSessionView from "../../src/frontend/SharedSessionView";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
vi.mock("react-xtermjs", () => ({
|
||||
useXTerm: () => ({
|
||||
instance: {
|
||||
options: {},
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
},
|
||||
ref: { current: document.createElement("div") },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: class FitAddon {
|
||||
fit() {}
|
||||
},
|
||||
}));
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
const guestView = locales.sessionSharing.guestView;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
function httpError(status: number) {
|
||||
return Object.assign(new Error(`HTTP ${status}`), { response: { status } });
|
||||
}
|
||||
|
||||
/** The shared-link page, rendered as a guest inside the plugin's scope. */
|
||||
async function renderShared(search: string, get: PluginApiClient["get"]) {
|
||||
window.history.pushState({}, "", `/?${search}`);
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
guest: true,
|
||||
api: { get } as unknown as PluginApiClient,
|
||||
});
|
||||
rendered.app.registerComponent(
|
||||
"session-sharing.testSharedView",
|
||||
SharedSessionView as ComponentType<Record<string, unknown>>,
|
||||
);
|
||||
rendered.app.registerSlotContribution("session.remoteDisplay", {
|
||||
actionId: "fixture.display",
|
||||
titleKey: "nav.collab",
|
||||
kind: "component",
|
||||
component: () => <div data-testid="remote-display" />,
|
||||
});
|
||||
render(<PluginComponent id="session-sharing.testSharedView" />);
|
||||
}
|
||||
|
||||
describe("SharedSessionView", () => {
|
||||
it("says the link is invalid when there is no token", async () => {
|
||||
const get = vi.fn();
|
||||
await renderShared("view=shared", get as never);
|
||||
expect(await screen.findByText(guestView.linkInvalid)).toBeTruthy();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says the link is invalid for a revoked or expired link", async () => {
|
||||
await renderShared(
|
||||
"view=shared&token=gone",
|
||||
vi.fn(async () => {
|
||||
throw httpError(404);
|
||||
}) as never,
|
||||
);
|
||||
expect(await screen.findByText(guestView.linkInvalid)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asks the guest to wait when rate limited", async () => {
|
||||
await renderShared(
|
||||
"view=shared&token=busy",
|
||||
vi.fn(async () => {
|
||||
throw httpError(429);
|
||||
}) as never,
|
||||
);
|
||||
expect(await screen.findByText(guestView.rateLimited)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("draws a remote desktop share through the display slot", async () => {
|
||||
const get = vi.fn(async () => ({
|
||||
data: {
|
||||
protocol: "rdp",
|
||||
permissionLevel: "read-only",
|
||||
wsPath: "/plugin-ws/remote-desktop/display",
|
||||
connectParams: { token: "t" },
|
||||
},
|
||||
}));
|
||||
await renderShared("view=shared&token=ok", get as never);
|
||||
expect(await screen.findByTestId("remote-display")).toBeTruthy();
|
||||
expect(screen.getByText(guestView.readOnlyBadge)).toBeTruthy();
|
||||
expect(get).toHaveBeenCalledWith("/resolve/ok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
invokeAction,
|
||||
type PluginApiClient,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
|
||||
return {
|
||||
get: vi.fn(async (url: string) => ({
|
||||
data:
|
||||
url === "/directory"
|
||||
? { users: [{ id: "bob", username: "bob" }], roles: [] }
|
||||
: url.includes("/active")
|
||||
? { shares: [] }
|
||||
: url === "/rooms"
|
||||
? { rooms: [] }
|
||||
: {},
|
||||
})),
|
||||
post: vi.fn(async () => ({
|
||||
data: { shareId: "s1", linkToken: "tok", expiresAt: "2999-01-01" },
|
||||
})),
|
||||
put: vi.fn(async () => ({ data: {} })),
|
||||
patch: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: {} })),
|
||||
...overrides,
|
||||
} as PluginApiClient;
|
||||
}
|
||||
|
||||
describe("session-sharing activate", () => {
|
||||
it("registers the rooms rail item, panel and tab, and the share buttons", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
|
||||
expect(rendered.registered.railItems()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "collab",
|
||||
permission: "session-sharing.use",
|
||||
}),
|
||||
]);
|
||||
expect(rendered.registered.panels()).toEqual(["collab"]);
|
||||
expect(rendered.registered.tabs()).toEqual(["collab"]);
|
||||
expect(rendered.registered.slot("terminal.toolbar")).toEqual([
|
||||
"session-sharing.share",
|
||||
]);
|
||||
expect(rendered.registered.slot("remote-desktop.toolbar")).toEqual([
|
||||
"session-sharing.share",
|
||||
]);
|
||||
expect(rendered.registered.slot("shell.overlay")).toHaveLength(2);
|
||||
expect(rendered.registered.actions().sort()).toEqual([
|
||||
"session-sharing.share",
|
||||
"sessions.sharedWithMe",
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers only the guest views on a guest page", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
guest: true,
|
||||
});
|
||||
expect(rendered.registered.tabs()).toEqual(["collab"]);
|
||||
expect(rendered.registered.railItems()).toEqual([]);
|
||||
expect(rendered.registered.actions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("opens the share dialog for a terminal's session", async () => {
|
||||
const api = stubApi();
|
||||
rendered = await renderWithApp(plugin, { manifest, locales, api });
|
||||
rendered.renderSlot("shell.overlay");
|
||||
|
||||
await act(() =>
|
||||
invokeAction("session-sharing.share", {
|
||||
getShareTarget: () => ({
|
||||
hostId: 4,
|
||||
sessionId: "sess-9",
|
||||
protocol: "ssh",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(locales.sessionSharing.modalTitle),
|
||||
).toBeTruthy();
|
||||
await waitFor(() => expect(api.get).toHaveBeenCalledWith("/host/4/active"));
|
||||
});
|
||||
|
||||
it("opens the dialog for a remote desktop session once it is up", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
rendered.renderSlot("shell.overlay");
|
||||
|
||||
await act(() =>
|
||||
invokeAction("session-sharing.share", {
|
||||
hostId: 2,
|
||||
sessionId: null,
|
||||
protocol: "rdp",
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText(locales.sessionSharing.modalTitle)).toBeNull();
|
||||
|
||||
await act(() =>
|
||||
invokeAction("session-sharing.share", {
|
||||
hostId: 2,
|
||||
sessionId: "guac-1",
|
||||
protocol: "rdp",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(locales.sessionSharing.modalTitle),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creates a link share from the dialog", async () => {
|
||||
const api = stubApi();
|
||||
rendered = await renderWithApp(plugin, { manifest, locales, api });
|
||||
rendered.renderSlot("shell.overlay");
|
||||
await act(() =>
|
||||
invokeAction("session-sharing.share", {
|
||||
getShareTarget: () => ({
|
||||
hostId: 1,
|
||||
sessionId: "sess-1",
|
||||
protocol: "ssh",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: locales.sessionSharing.createLinkButton,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/create",
|
||||
expect.objectContaining({
|
||||
hostId: 1,
|
||||
sessionId: "sess-1",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("answers sessions shared with the user through the api", async () => {
|
||||
const shared = [{ sessionId: "s", isOwnSession: false }];
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi({
|
||||
get: vi.fn(async () => ({ data: shared })) as never,
|
||||
}),
|
||||
});
|
||||
await expect(invokeAction("sessions.sharedWithMe")).resolves.toEqual(
|
||||
shared,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
await app.deactivate();
|
||||
expect(app.registered.tabs()).toEqual([]);
|
||||
expect(app.registered.panels()).toEqual([]);
|
||||
expect(app.registered.slot("terminal.toolbar")).toEqual([]);
|
||||
expect(app.registered.actions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user