mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
221 lines
6.5 KiB
TypeScript
221 lines
6.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { Request } from "express";
|
|
import type { UserRecord } from "../../../database/repositories/user-repository.js";
|
|
import {
|
|
allowsDesktopAutoSession,
|
|
isLoopbackRequest,
|
|
extractBearerOrCookieToken,
|
|
isNativeTokenExportRequest,
|
|
resolveDesktopAutoSessionUser,
|
|
} from "../../../database/routes/desktop-auto-session.js";
|
|
|
|
function makeUser(overrides: Partial<UserRecord> = {}): UserRecord {
|
|
return {
|
|
id: "user-1",
|
|
username: "local",
|
|
passwordHash: "",
|
|
isOidc: false,
|
|
isAdmin: false,
|
|
registeredAt: "2026-01-01T00:00:00.000Z",
|
|
...overrides,
|
|
} as UserRecord;
|
|
}
|
|
|
|
describe("isLoopbackRequest", () => {
|
|
it.each(["127.0.0.1", "::1", "::ffff:127.0.0.1"])(
|
|
"accepts %s as loopback",
|
|
(ip) => {
|
|
expect(
|
|
isLoopbackRequest({
|
|
ip,
|
|
headers: {},
|
|
socket: { remoteAddress: ip },
|
|
} as unknown as Request),
|
|
).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("accepts an IPv4-mapped loopback suffix", () => {
|
|
expect(
|
|
isLoopbackRequest({
|
|
ip: "::ffff:127.0.0.1",
|
|
headers: {},
|
|
socket: { remoteAddress: "::ffff:127.0.0.1" },
|
|
} as unknown as Request),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects a non-loopback TCP peer address", () => {
|
|
expect(
|
|
isLoopbackRequest({
|
|
ip: "192.168.1.50",
|
|
headers: {},
|
|
socket: { remoteAddress: "192.168.1.50" },
|
|
} as unknown as Request),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("ignores a spoofed X-Forwarded-For value", () => {
|
|
expect(
|
|
isLoopbackRequest({
|
|
ip: "127.0.0.1",
|
|
headers: { "x-forwarded-for": "127.0.0.1" },
|
|
socket: { remoteAddress: "203.0.113.10" },
|
|
} as unknown as Request),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("rejects requests that traversed the reverse proxy (X-Real-IP set)", () => {
|
|
expect(
|
|
isLoopbackRequest({
|
|
ip: "127.0.0.1",
|
|
headers: { "x-real-ip": "203.0.113.10" },
|
|
socket: { remoteAddress: "127.0.0.1" },
|
|
} as unknown as Request),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("extractBearerOrCookieToken", () => {
|
|
it("prefers the jwt cookie over the Authorization header", () => {
|
|
const req = {
|
|
cookies: { jwt: "cookie-token" },
|
|
headers: { authorization: "Bearer header-token" },
|
|
} as unknown as Request;
|
|
expect(extractBearerOrCookieToken(req)).toBe("cookie-token");
|
|
});
|
|
|
|
it("falls back to a Bearer Authorization header", () => {
|
|
const req = {
|
|
cookies: {},
|
|
headers: { authorization: "Bearer header-token" },
|
|
} as unknown as Request;
|
|
expect(extractBearerOrCookieToken(req)).toBe("header-token");
|
|
});
|
|
|
|
it("returns undefined when neither is present", () => {
|
|
const req = { cookies: {}, headers: {} } as unknown as Request;
|
|
expect(extractBearerOrCookieToken(req)).toBeUndefined();
|
|
});
|
|
|
|
it("ignores a non-Bearer Authorization header", () => {
|
|
const req = {
|
|
cookies: {},
|
|
headers: { authorization: "Basic abc123" },
|
|
} as unknown as Request;
|
|
expect(extractBearerOrCookieToken(req)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("isNativeTokenExportRequest", () => {
|
|
function requestWithUserAgent(userAgent: string): Request {
|
|
return {
|
|
get: (name: string) =>
|
|
name.toLowerCase() === "user-agent" ? userAgent : undefined,
|
|
} as unknown as Request;
|
|
}
|
|
|
|
it.each([
|
|
"Termix-Mobile/iOS",
|
|
"Termix-Mobile/Android",
|
|
"Termix-Desktop/2.8.0 (win32; Electron/43)",
|
|
])("allows native user agent %s", (userAgent) => {
|
|
expect(isNativeTokenExportRequest(requestWithUserAgent(userAgent))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it.each(["Mozilla/5.0", "Electron/43.0.0", "", "Termix-MobileFake/iOS"])(
|
|
"rejects non-native user agent %s",
|
|
(userAgent) => {
|
|
expect(isNativeTokenExportRequest(requestWithUserAgent(userAgent))).toBe(
|
|
false,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("resolveDesktopAutoSessionUser", () => {
|
|
it("returns the account the user switched to, while it exists", () => {
|
|
const admin = makeUser({ id: "admin", isAdmin: true });
|
|
const chosen = makeUser({ id: "chosen" });
|
|
expect(resolveDesktopAutoSessionUser([admin, chosen], "chosen")).toBe(
|
|
chosen,
|
|
);
|
|
});
|
|
|
|
it("falls back to the usual pick when the chosen account is gone", () => {
|
|
const admin = makeUser({ id: "admin", isAdmin: true });
|
|
const other = makeUser({ id: "other" });
|
|
expect(resolveDesktopAutoSessionUser([admin, other], "deleted")).toBe(
|
|
admin,
|
|
);
|
|
});
|
|
|
|
it("returns the sole local user regardless of having a real password", () => {
|
|
const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" });
|
|
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
|
});
|
|
|
|
it("returns the sole local user even when OIDC-enabled", () => {
|
|
const user = makeUser({ isOidc: true });
|
|
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
|
});
|
|
|
|
it("returns the auto-provisioned passwordless placeholder", () => {
|
|
const user = makeUser({ passwordHash: "" });
|
|
expect(resolveDesktopAutoSessionUser([user])).toBe(user);
|
|
});
|
|
|
|
it("declines when zero users exist", () => {
|
|
expect(resolveDesktopAutoSessionUser([])).toBeNull();
|
|
});
|
|
|
|
it("never declines for a multi-user local database -- prefers the admin account", () => {
|
|
const admin = makeUser({
|
|
id: "user-2",
|
|
isAdmin: true,
|
|
registeredAt: "2026-02-01T00:00:00.000Z",
|
|
});
|
|
const result = resolveDesktopAutoSessionUser([
|
|
makeUser({
|
|
id: "user-1",
|
|
isAdmin: false,
|
|
registeredAt: "2026-01-01T00:00:00.000Z",
|
|
}),
|
|
admin,
|
|
]);
|
|
expect(result).toBe(admin);
|
|
});
|
|
|
|
it("falls back to the earliest-registered account when no admin exists", () => {
|
|
const earliest = makeUser({
|
|
id: "user-1",
|
|
registeredAt: "2026-01-01T00:00:00.000Z",
|
|
});
|
|
const result = resolveDesktopAutoSessionUser([
|
|
makeUser({ id: "user-2", registeredAt: "2026-03-01T00:00:00.000Z" }),
|
|
earliest,
|
|
makeUser({ id: "user-3", registeredAt: "2026-02-01T00:00:00.000Z" }),
|
|
]);
|
|
expect(result).toBe(earliest);
|
|
});
|
|
});
|
|
|
|
describe("allowsDesktopAutoSession", () => {
|
|
it("allows the embedded desktop backend and a development backend", () => {
|
|
expect(
|
|
allowsDesktopAutoSession({
|
|
ELECTRON_EMBEDDED: "true",
|
|
NODE_ENV: "production",
|
|
}),
|
|
).toBe(true);
|
|
expect(allowsDesktopAutoSession({ NODE_ENV: "development" })).toBe(true);
|
|
expect(allowsDesktopAutoSession({})).toBe(true);
|
|
});
|
|
|
|
it("never allows it on a production server", () => {
|
|
expect(allowsDesktopAutoSession({ NODE_ENV: "production" })).toBe(false);
|
|
});
|
|
});
|