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

@@ -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([]);
});
});