mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move session sharing and collab rooms into a plugin (B12)
This commit is contained in:
1 parent
791d8656a1
commit
a276f6898c
207 files changed
+35771
-11072
No files matched your search
@@ -1,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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user