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

@@ -1,94 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const axiosGetMock = vi.hoisted(() => vi.fn());
vi.mock("axios", () => ({
default: {
get: axiosGetMock,
isAxiosError: (err: unknown): err is { response?: { status?: number } } =>
typeof err === "object" && err !== null && "response" in err,
},
}));
vi.mock("@/lib/base-path", () => ({
getBasePath: () => "",
}));
vi.mock("@/lib/electron", () => ({
isElectron: () => false,
}));
vi.mock("@/main-axios", () => ({
getServerConfig: vi.fn(async () => null),
}));
import {
resolveShareLink,
ShareLinkError,
} from "../../api/session-sharing-api";
beforeEach(() => {
axiosGetMock.mockReset();
});
describe("resolveShareLink", () => {
it("returns the resolved share data on success", async () => {
axiosGetMock.mockResolvedValueOnce({
data: {
protocol: "ssh",
permissionLevel: "read-write",
wsPath: "/terminal/ws?shareToken=abc123",
},
});
const result = await resolveShareLink("abc123");
expect(result).toEqual({
protocol: "ssh",
permissionLevel: "read-write",
wsPath: "/terminal/ws?shareToken=abc123",
});
expect(axiosGetMock).toHaveBeenCalledWith(
expect.stringContaining("/session-sharing/resolve/abc123"),
);
});
it("throws a not-found ShareLinkError on 404", async () => {
axiosGetMock.mockRejectedValueOnce({ response: { status: 404 } });
await expect(resolveShareLink("bad-token")).rejects.toMatchObject({
kind: "not-found",
});
await expect(resolveShareLink("bad-token")).rejects.toBeInstanceOf(
ShareLinkError,
);
});
it("throws a rate-limited ShareLinkError on 429", async () => {
axiosGetMock.mockRejectedValueOnce({ response: { status: 429 } });
await expect(resolveShareLink("token")).rejects.toMatchObject({
kind: "rate-limited",
});
});
it("throws a generic ShareLinkError on unexpected failures", async () => {
axiosGetMock.mockRejectedValueOnce(new Error("network down"));
await expect(resolveShareLink("token")).rejects.toMatchObject({
kind: "unknown",
});
});
it("URL-encodes the link token", async () => {
axiosGetMock.mockResolvedValueOnce({
data: { protocol: "ssh", permissionLevel: "read-only", wsPath: "" },
});
await resolveShareLink("a/b c");
expect(axiosGetMock).toHaveBeenCalledWith(
expect.stringContaining(encodeURIComponent("a/b c")),
);
});
});
@@ -1,211 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
cleanup,
fireEvent,
} from "@testing-library/react";
const api = vi.hoisted(() => ({
createSessionShare: vi.fn(),
getActiveSessionShares: vi.fn(async () => ({ shares: [] })),
revokeSessionShare: vi.fn(async () => ({ success: true as const })),
}));
vi.mock("@/api/session-sharing-api", () => api);
const mainAxios = vi.hoisted(() => ({
getUserList: vi.fn(async () => ({
users: [
{ userId: "u1", username: "alice" },
{ userId: "u2", username: "bob" },
],
})),
}));
vi.mock("@/main-axios", () => mainAxios);
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
Object.assign(navigator, {
clipboard: { writeText: vi.fn(async () => {}) },
});
import { ShareSessionModal } from "../../../features/session-sharing/ShareSessionModal";
beforeEach(() => {
api.createSessionShare.mockReset();
api.getActiveSessionShares.mockReset();
api.getActiveSessionShares.mockResolvedValue({ shares: [] });
api.revokeSessionShare.mockReset();
mainAxios.getUserList.mockClear();
});
afterEach(() => {
cleanup();
});
describe("ShareSessionModal", () => {
it("creates a link share with the default read-only permission and 24h expiry", async () => {
api.createSessionShare.mockResolvedValue({
shareId: "share-1",
linkToken: "tok-123",
expiresAt: "2026-07-21T00:00:00.000Z",
});
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={42}
sessionId="sess-1"
protocol="ssh"
tabInstanceId="tab-1"
/>,
);
const createButton = await screen.findByText(
"sessionSharing.createLinkButton",
);
fireEvent.click(createButton);
await waitFor(() => {
expect(api.createSessionShare).toHaveBeenCalledWith({
hostId: 42,
sessionId: "sess-1",
tabInstanceId: "tab-1",
protocol: "ssh",
shareType: "link",
targetUserId: undefined,
permissionLevel: "read-only",
expiryHours: 24,
});
});
});
it("does not submit a user share until a target user is selected", async () => {
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={7}
sessionId="sess-2"
protocol="rdp"
/>,
);
fireEvent.click(await screen.findByText("sessionSharing.modeTab.user"));
const shareButton = await screen.findByText(
"sessionSharing.createShareButton",
);
expect((shareButton as HTMLButtonElement).disabled).toBe(true);
expect(api.createSessionShare).not.toHaveBeenCalled();
});
it("submits a user share with the selected target and read-write permission", async () => {
api.createSessionShare.mockResolvedValue({
shareId: "share-2",
linkToken: null,
expiresAt: "2026-07-21T00:00:00.000Z",
});
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={7}
sessionId="sess-2"
protocol="vnc"
/>,
);
fireEvent.click(await screen.findByText("sessionSharing.modeTab.user"));
fireEvent.click(await screen.findByText("alice"));
const select = await screen.findByDisplayValue(
"sessionSharing.permissionLevel.readOnly",
);
fireEvent.change(select, { target: { value: "read-write" } });
fireEvent.click(screen.getByText("sessionSharing.createShareButton"));
await waitFor(() => {
expect(api.createSessionShare).toHaveBeenCalledWith({
hostId: 7,
sessionId: "sess-2",
tabInstanceId: undefined,
protocol: "vnc",
shareType: "user",
targetUserId: "u1",
permissionLevel: "read-write",
expiryHours: 24,
});
});
});
it("does not call create when there is no live session id", async () => {
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={1}
sessionId={null}
protocol="ssh"
/>,
);
const createButton = await screen.findByText(
"sessionSharing.createLinkButton",
);
expect((createButton as HTMLButtonElement).disabled).toBe(true);
});
it("loads and renders active shares for the host", async () => {
api.getActiveSessionShares.mockResolvedValue({
shares: [
{
id: "share-x",
hostId: 42,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "sess-1",
tabInstanceId: null,
shareType: "link",
targetUserId: null,
linkToken: "tok-abc",
permissionLevel: "read-only",
createdAt: "2026-07-20T00:00:00.000Z",
expiresAt: "2026-07-21T00:00:00.000Z",
revokedAt: null,
lastJoinedAt: null,
joinCount: 0,
},
],
});
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={42}
sessionId="sess-1"
protocol="ssh"
/>,
);
await waitFor(() => {
expect(screen.getByText("sessionSharing.linkShareBadge")).toBeTruthy();
});
});
});
@@ -1,137 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
const api = vi.hoisted(() => ({
resolveShareLink: vi.fn(async (): Promise<unknown> => {
throw new Error("resolveShareLink not mocked for this test");
}),
ShareLinkError: class ShareLinkError extends Error {
kind: string;
constructor(message: string, kind: string) {
super(message);
this.kind = kind;
}
},
}));
vi.mock("@/api/session-sharing-api", () => api);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("react-xtermjs", () => ({
useXTerm: () => ({
instance: {
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() {}
},
}));
import SharedSessionView from "../../../features/session-sharing/SharedSessionView";
import {
registerSlotContribution,
resetActionRegistry,
} from "@/shell/action-registry";
import { REMOTE_DISPLAY_SLOT } from "@/features/remote-display/RemoteDisplay";
import { setPermissionsForTesting } from "@/hooks/use-permissions";
function setSearch(search: string) {
window.history.pushState({}, "", `/?${search}`);
}
beforeEach(() => {
setPermissionsForTesting([]);
registerSlotContribution(REMOTE_DISPLAY_SLOT, {
actionId: "fixture.display",
titleKey: "x",
kind: "component",
component: () => <div data-testid="remote-display" />,
});
api.resolveShareLink.mockReset();
api.resolveShareLink.mockImplementation(async () => {
throw new Error("resolveShareLink not mocked for this test");
});
});
afterEach(() => {
cleanup();
resetActionRegistry();
});
describe("SharedSessionView", () => {
it("shows a friendly error when no token is present in the URL", async () => {
setSearch("view=shared");
render(<SharedSessionView />);
await waitFor(() => {
expect(
screen.getByText("sessionSharing.guestView.linkInvalid"),
).toBeTruthy();
});
expect(api.resolveShareLink).not.toHaveBeenCalled();
});
it("shows a generic error when the link is invalid or expired", async () => {
setSearch("view=shared&token=bad");
api.resolveShareLink.mockRejectedValue(
new api.ShareLinkError("not found", "not-found"),
);
render(<SharedSessionView />);
await waitFor(() => {
expect(
screen.getByText("sessionSharing.guestView.linkInvalid"),
).toBeTruthy();
});
});
it("shows a rate-limit specific message on 429", async () => {
setSearch("view=shared&token=abc");
api.resolveShareLink.mockRejectedValue(
new api.ShareLinkError("slow down", "rate-limited"),
);
render(<SharedSessionView />);
await waitFor(() => {
expect(api.resolveShareLink).toHaveBeenCalledWith("abc");
});
await waitFor(() => {
expect(
screen.getByText("sessionSharing.guestView.rateLimited"),
).toBeTruthy();
});
});
it("renders the plugin remote display for a resolved rdp share", async () => {
setSearch("view=shared&token=abc");
api.resolveShareLink.mockResolvedValue({
protocol: "rdp",
permissionLevel: "read-only",
wsPath: "/plugin-ws/remote-desktop/display",
connectParams: { token: "guac-join-token" },
});
render(<SharedSessionView />);
await waitFor(() => {
expect(screen.getByTestId("remote-display")).toBeTruthy();
});
expect(
screen.getByText("sessionSharing.guestView.readOnlyBadge"),
).toBeTruthy();
});
});
+2 -3
View File
@@ -49,14 +49,13 @@ describe("RAIL_ITEMS", () => {
// the settings toggles, the sidebar titles and the mobile bar all read
// from this one list.
// "sftp" moved to the file-manager plugin's own registerRailItem,
// "port-forwarding" to the tunnels plugin's, and "serial" to the serial
// plugin's.
// "port-forwarding" to the tunnels plugin's, "serial" to the serial
// plugin's, and "collab" to the session-sharing plugin's.
expect(RAIL_ITEMS.map((item) => item.id)).toEqual([
"hosts",
"credentials",
"termix-id",
"connections",
"collab",
"quick-connect",
"ssh-tools",
"macros",