mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* The ctx.ssh members an interactive transport (the terminal) needs: host
|
||||
* resolution with secrets, prepare with its provider hooks bound, and the
|
||||
* browser sign-in start and cancel that used to be reached by import.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
actor: "user-1" as string | undefined,
|
||||
resolved: [] as Array<{ by: string; ref: unknown; userId: string }>,
|
||||
built: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
|
||||
});
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../hosts/connect/core-providers.js", () => ({
|
||||
ensureCoreSshAuthProviders: () => {},
|
||||
}));
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (id: number, userId: string) => {
|
||||
h.resolved.push({ by: "id", ref: id, userId });
|
||||
return id === 404 ? null : { id, ip: "10.0.0.1", password: "pw" };
|
||||
},
|
||||
resolveHostBySyncId: async (syncId: string, userId: string) => {
|
||||
h.resolved.push({ by: "syncId", ref: syncId, userId });
|
||||
return { id: 9, ip: "10.0.0.9" };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/connect/build-connect-config.js", () => ({
|
||||
buildConnectConfig: async (
|
||||
_host: unknown,
|
||||
options: Record<string, unknown>,
|
||||
) => {
|
||||
h.built.push(options);
|
||||
return {
|
||||
config: { host: "10.0.0.1" },
|
||||
outcome: { status: "ready" },
|
||||
env: { interactive: options.interactive },
|
||||
provider: {
|
||||
type: "fixture-auth",
|
||||
onBanner: (banner: string, _host: unknown, env: unknown) => ({
|
||||
action: "hold",
|
||||
timeoutMs: 1,
|
||||
message: banner,
|
||||
details: { env },
|
||||
}),
|
||||
onAuthFailed: () => ({ status: "retry", patch: {}, message: "again" }),
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostById: async (hostId: number) =>
|
||||
hostId === 1
|
||||
? {
|
||||
id: 1,
|
||||
name: "box",
|
||||
ip: "10.0.0.1",
|
||||
username: "root",
|
||||
authType: "fixture-auth",
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createPluginSsh } = await import("../../plugins/ctx-ssh-auth.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { registerSshAuthProvider, resetSshAuthRegistryForTests } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const { PluginCapabilityError, PluginSshInteractionError } =
|
||||
await import("@termix/plugin-sdk/backend");
|
||||
|
||||
const ALL = ["ssh:connect", "credentials:use"];
|
||||
|
||||
function sshWith(capabilities = ALL) {
|
||||
const audit = vi.fn(async () => {});
|
||||
const ssh = createPluginSsh({
|
||||
manifest: {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never,
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
return { ssh, audit };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.granted = new Set(ALL);
|
||||
h.actor = "user-1";
|
||||
h.resolved = [];
|
||||
h.built = [];
|
||||
resetSshAuthRegistryForTests();
|
||||
});
|
||||
|
||||
describe("ctx.ssh.resolveHost", () => {
|
||||
it("resolves by sync id first, as the acting user, and audits", async () => {
|
||||
const { ssh, audit } = sshWith();
|
||||
expect(await ssh.resolveHost(3, { syncId: "abc" })).toMatchObject({
|
||||
id: 9,
|
||||
});
|
||||
expect(await ssh.resolveHost(3)).toMatchObject({ id: 3, password: "pw" });
|
||||
expect(h.resolved).toEqual([
|
||||
{ by: "syncId", ref: "abc", userId: "user-1" },
|
||||
{ by: "id", ref: 3, userId: "user-1" },
|
||||
]);
|
||||
expect(audit).toHaveBeenCalledWith("ssh_resolve_host", "host 3", {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers null for a host this user cannot reach", async () => {
|
||||
const { ssh } = sshWith();
|
||||
expect(await ssh.resolveHost(404)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses without credentials:use", async () => {
|
||||
h.granted = new Set(["ssh:connect"]);
|
||||
const { ssh } = sshWith(["ssh:connect"]);
|
||||
await expect(ssh.resolveHost(3)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(h.resolved).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh.prepare", () => {
|
||||
it("passes the terminal purpose, interactivity and host key socket through", async () => {
|
||||
const { ssh } = sshWith();
|
||||
const socket = { send: vi.fn() };
|
||||
await ssh.prepare(
|
||||
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
|
||||
{
|
||||
purpose: "terminal",
|
||||
client: {},
|
||||
interactive: true,
|
||||
hostKeySocket: socket,
|
||||
},
|
||||
);
|
||||
expect(h.built[0]).toMatchObject({
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
hostKeySocket: socket,
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("hands back the provider's hooks bound to this host", async () => {
|
||||
const { ssh } = sshWith();
|
||||
const prepared = await ssh.prepare(
|
||||
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
|
||||
{ client: {}, interactive: true },
|
||||
);
|
||||
expect(prepared.authType).toBe("fixture-auth");
|
||||
expect(prepared.onBanner?.("waiting")).toMatchObject({
|
||||
action: "hold",
|
||||
message: "waiting",
|
||||
details: { env: { interactive: true } },
|
||||
});
|
||||
expect(
|
||||
prepared.onAuthFailed?.({
|
||||
error: new Error("x"),
|
||||
retries: 0,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
}),
|
||||
).toMatchObject({ status: "retry" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh browser sign-in", () => {
|
||||
it("starts the interaction on the host's own provider", async () => {
|
||||
const started = vi.fn(async () => {});
|
||||
registerSshAuthProvider({
|
||||
type: "fixture-auth",
|
||||
pluginId: "core",
|
||||
labelKey: "k",
|
||||
interaction: "fixture",
|
||||
startInteraction: started,
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
const { ssh } = sshWith();
|
||||
await ssh.startInteraction("fixture", {
|
||||
hostId: 1,
|
||||
socket: "ws",
|
||||
requestOrigin: "https://termix.example",
|
||||
});
|
||||
expect(started).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
socket: "ws",
|
||||
requestOrigin: "https://termix.example",
|
||||
host: { name: "box", ip: "10.0.0.1", username: "root" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("says why when nothing can start it", async () => {
|
||||
const { ssh } = sshWith();
|
||||
await expect(
|
||||
ssh.startInteraction("fixture", {
|
||||
hostId: 1,
|
||||
socket: null,
|
||||
requestOrigin: "",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PluginSshInteractionError);
|
||||
await expect(
|
||||
ssh.startInteraction("fixture", {
|
||||
hostId: 2,
|
||||
socket: null,
|
||||
requestOrigin: "",
|
||||
}),
|
||||
).rejects.toThrow("Host not found");
|
||||
});
|
||||
|
||||
it("cancels on every provider that owns the interaction", async () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const other = vi.fn();
|
||||
for (const [type, interaction, cancel] of [
|
||||
["a", "fixture", first],
|
||||
["b", "fixture", second],
|
||||
["c", "elsewhere", other],
|
||||
] as const) {
|
||||
registerSshAuthProvider({
|
||||
type,
|
||||
pluginId: "core",
|
||||
labelKey: "k",
|
||||
interaction,
|
||||
cancelInteraction: cancel,
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
}
|
||||
const { ssh } = sshWith();
|
||||
await ssh.cancelInteraction("fixture", { requestId: "r1" });
|
||||
expect(first).toHaveBeenCalledWith({ userId: "user-1", requestId: "r1" });
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user