* 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>
This commit is contained in:
authored and GitHub committed 2026-10-20 16:03:29 -05:00
1 parent aff29a9581
commit a89ccc6b07
577 files changed
+76479 -101831

No files matched your search

@@ -97,6 +97,8 @@ const { getPasswordLoginStatus } = await import("../../auth/core-auth.js");
const { registerLoginMethod, registerSecondFactor } =
await import("../../auth/registry.js");
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
const { provideAuthPolicy, clearAuthPolicies, SECOND_FACTOR_SETUP } =
await import("../../auth/policy.js");
const PASSWORD = "correct-horse";
// Stands in for the totp plugin's factor: core only sees the registration.
@@ -137,6 +139,7 @@ beforeEach(() => {
delete process.env.ALLOW_PASSWORD_LOGIN;
disposeTotp?.();
registerTotpFixture();
clearAuthPolicies();
});
async function passwordLogin(body: Record<string, unknown>, headers = {}) {
@@ -891,3 +894,219 @@ describe("lockout guard", () => {
});
});
});
describe("login policy", () => {
function enrolTotp() {
h.state.factors.push({ userId: "u1", pluginId: "totp", factorId: "totp" });
}
function trustedLogin(token: string) {
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
cookies: { [TRUST_DEVICE_COOKIE]: token },
});
return runLogin(
req as never,
{ kind: "user", userId: "u1" },
{ methodId: "password", rememberMe: true },
);
}
it("hands the provider the user's factors and the device", async () => {
addUser();
enrolTotp();
const evaluate = vi.fn(() => ({}));
provideAuthPolicy("policy", { evaluate });
await passwordLogin({ username: "alice", password: PASSWORD });
expect(evaluate).toHaveBeenCalledWith({
userId: "u1",
methodId: "password",
external: false,
deviceType: "web",
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [{ id: "totp", pluginId: "totp", enrolled: true }],
});
});
it("turns remember me off and caps the session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 600 }),
});
const res = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 600 }),
);
expect((res.cookies[0].options as { maxAge: number }).maxAge).toBe(
600 * 1000,
);
});
it("stores the idle timeout on the session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ idleTimeoutSeconds: 900 }),
});
provideAuthPolicy("other", {
evaluate: () => ({ idleTimeoutSeconds: 300 }),
});
await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ idleTimeoutSeconds: 300 }),
);
});
it("ignores a remembered device when remember me is off", async () => {
addUser();
enrolTotp();
const token = "c".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false }),
});
const result = await trustedLogin(token);
expect(result.kind).toBe("second-factor");
});
it("asks for the factor on a remembered device with always, not with new-device", async () => {
addUser();
enrolTotp();
const token = "d".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
const remove = provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
expect((await trustedLogin(token)).kind).toBe("second-factor");
remove();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "new-device" }),
});
expect((await trustedLogin(token)).kind).toBe("session");
});
it("gives a user without a factor a setup only session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ restriction: SECOND_FACTOR_SETUP }),
);
expect(res.body).toMatchObject({ restriction: SECOND_FACTOR_SETUP });
});
it("lets a verified passkey satisfy a required factor", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
);
expect(result.kind).toBe("session");
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ restriction: null }),
);
});
it("runs factors for SSO logins when the policy requires one", async () => {
const dispose = registerLoginMethod({
id: "oidc",
pluginId: "sso",
labelKey: "loginWithSso",
kind: "redirect",
external: true,
});
try {
addUser({ passwordHash: "" });
enrolTotp();
h.state.identities.push({
id: 1,
userId: "u1",
providerId: "3",
subject: "sub-1",
email: null,
});
provideAuthPolicy("policy", {
evaluate: (input) => (input.external ? { secondFactor: "always" } : {}),
});
const result = await runLogin(
fakeRequest() as never,
{
kind: "external",
provider: "3",
subject: "sub-1",
email: null,
name: "Alice",
} as never,
{ methodId: "oidc", rememberMe: false },
);
expect(result.kind).toBe("second-factor");
} finally {
dispose();
}
});
it("keeps the policy for the factor step: no trusted device, same cap", async () => {
addUser();
enrolTotp();
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 1200 }),
});
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: TOTP_CODE,
rememberMe: true,
},
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(200);
expect(res.cookies.map((c) => c.name)).toEqual(["jwt"]);
expect(h.state.trustedAdded).toEqual([]);
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
"u1",
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 1200 }),
);
});
it("refuses the login when a provider throws", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => {
throw new Error("boom");
},
});
await expect(
runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1" },
{ methodId: "password", rememberMe: false },
),
).rejects.toMatchObject({ status: 503, code: "auth_policy_unavailable" });
expect(h.state.audits).toEqual([]);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
authLogger: { warn: vi.fn() },
}));
const {
clearAuthPolicies,
evaluateAuthPolicy,
hasAuthPolicies,
provideAuthPolicy,
rememberDeviceAllowed,
} = await import("../../auth/policy.js");
const input = {
userId: "u1",
methodId: "password",
external: false,
deviceType: "web" as const,
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [],
};
beforeEach(() => clearAuthPolicies());
describe("auth policy", () => {
it("changes nothing without a provider", async () => {
expect(hasAuthPolicies()).toBe(false);
expect(await evaluateAuthPolicy(input)).toEqual({
allowRememberDevice: true,
maxSessionSeconds: null,
idleTimeoutSeconds: null,
secondFactor: null,
});
expect(rememberDeviceAllowed()).toBe(true);
});
it("takes the strictest answer from every provider", async () => {
provideAuthPolicy("a", {
evaluate: () => ({
maxSessionSeconds: 3600,
idleTimeoutSeconds: 900,
secondFactor: "new-device",
}),
});
provideAuthPolicy("b", {
evaluate: async () => ({
allowRememberDevice: false,
maxSessionSeconds: 7200,
idleTimeoutSeconds: 300,
secondFactor: "always",
}),
allowRememberDevice: () => false,
});
expect(await evaluateAuthPolicy(input)).toEqual({
allowRememberDevice: false,
maxSessionSeconds: 3600,
idleTimeoutSeconds: 300,
secondFactor: "always",
});
expect(rememberDeviceAllowed()).toBe(false);
});
it("ignores zero and negative limits", async () => {
provideAuthPolicy("a", {
evaluate: () => ({ maxSessionSeconds: 0, idleTimeoutSeconds: -5 }),
});
expect(await evaluateAuthPolicy(input)).toMatchObject({
maxSessionSeconds: null,
idleTimeoutSeconds: null,
});
});
it("refuses the login when a provider throws, until it is removed", async () => {
const remove = provideAuthPolicy("a", {
evaluate: () => {
throw new Error("boom");
},
});
await expect(evaluateAuthPolicy(input)).rejects.toMatchObject({
status: 503,
code: "auth_policy_unavailable",
});
remove();
await expect(evaluateAuthPolicy(input)).resolves.toMatchObject({
secondFactor: null,
});
});
it("hides remember me when the hint throws", () => {
provideAuthPolicy("a", {
evaluate: () => ({}),
allowRememberDevice: () => {
throw new Error("boom");
},
});
expect(rememberDeviceAllowed()).toBe(false);
});
});
@@ -1,99 +0,0 @@
import { sql } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js";
describe("RecentActivityRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<{
repository: RecentActivityRepository;
}> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp)
VALUES
(1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'),
(2, 'user-1', 'disconnect', 2, 'two', '2026-06-26T00:01:00.000Z'),
(3, 'user-2', 'connect', 3, 'other', '2026-06-26T00:02:00.000Z');
`);
return {
repository: new RecentActivityRepository(context, onWrite),
};
}
it("lists, creates, and trims recent activity", async () => {
let writeCount = 0;
const { repository } = await createRepository(() => {
writeCount += 1;
});
expect(
(await repository.listByUserId("user-1", 2)).map((row) => row.id),
).toEqual([2, 1]);
const created = await repository.create({
userId: "user-1",
type: "terminal",
hostId: 1,
hostName: "one",
timestamp: "2026-06-26T00:03:00.000Z",
});
expect(created).toMatchObject({
userId: "user-1",
type: "terminal",
hostId: 1,
});
expect(await repository.trimUserActivity("user-1", 2)).toBe(1);
expect(
await adapter!.query(
sql`SELECT id FROM recent_activity WHERE user_id = 'user-1' ORDER BY timestamp DESC`,
),
).toEqual([{ id: created.id }, { id: 2 }]);
expect(writeCount).toBe(2);
});
it("deletes activity by user id and only triggers writes for changed rows", async () => {
let writeCount = 0;
const { repository: repo } = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(0);
expect(await repo.deleteByUserId("user-1")).toBe(2);
expect(writeCount).toBe(1);
expect(await repo.deleteByUserId("user-1")).toBe(0);
expect(writeCount).toBe(1);
});
it("deletes activity by host id and host id list", async () => {
let writeCount = 0;
const { repository: repo } = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteByHostId(1)).toBe(1);
expect(await repo.deleteByHostId(1)).toBe(0);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2, 3])).toBe(2);
expect(writeCount).toBe(2);
});
});
@@ -1,5 +1,4 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import { beforeEach, describe, expect, it, vi } from "vitest";
import express, {
type NextFunction,
type Request,
@@ -19,15 +18,6 @@ vi.mock("../../../utils/logger.js", () => ({
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null,
set: async (key: string, value: string) => {
state.settings[key] = value;
},
setMany: async (writes: Array<{ key: string; value: string }>) => {
for (const write of writes) state.settings[write.key] = write.value;
},
delete: async (key: string) => {
delete state.settings[key];
},
}),
}));
@@ -42,25 +32,17 @@ interface RouteLayer {
};
}
/** Runs a route's full middleware chain (e.g. requireAdmin, then the handler). */
function handlersFor(
router: express.Router,
pathName: string,
method: string,
): RequestHandler[] {
const layer = (router as unknown as { stack: RouteLayer[] }).stack.find(
(candidate) =>
candidate.route?.path === pathName && candidate.route.methods[method],
);
if (!layer) throw new Error(`Route not registered: ${method} ${pathName}`);
return layer.route!.stack.map((entry) => entry.handle);
function routesOf(router: express.Router) {
return (router as unknown as { stack: RouteLayer[] }).stack
.filter((layer) => layer.route)
.map((layer) => ({
path: layer.route!.path,
methods: Object.keys(layer.route!.methods),
handlers: layer.route!.stack.map((entry) => entry.handle),
}));
}
async function invokeChain(
handlers: RequestHandler[],
body: unknown = {},
): Promise<{ statusCode: number; body: unknown }> {
const req = { body, headers: {} } as unknown as Request;
async function call(handler: RequestHandler) {
const result = { statusCode: 200, body: null as unknown };
const res = {
status(code: number) {
@@ -72,67 +54,34 @@ async function invokeChain(
return this;
},
} as unknown as Response;
for (const handler of handlers) {
let proceeded = false;
await handler(req, res, ((err?: unknown) => {
if (err) throw err;
proceeded = true;
}) as NextFunction);
if (!proceeded) return result;
}
await handler(
{ body: {}, headers: {} } as Request,
res,
(() => {}) as NextFunction,
);
return result;
}
/** Mimics the real requireAdmin: 401 with no session, 403 for a non-admin. */
function fakeRequireAdmin(
outcome: "unauthenticated" | "not-admin" | "admin",
): RequestHandler {
return (_req, res, next) => {
if (outcome === "unauthenticated") {
res.status(401).json({ error: "Missing authentication token" });
return;
}
if (outcome === "not-admin") {
res.status(403).json({ error: "Not authorized" });
return;
}
// Must return (not just call) next() so invokeChain's await actually
// waits for the downstream async handler to finish before returning.
return next();
};
}
function buildRouter(requireAdmin: RequestHandler) {
const router = express.Router();
registerBrandingRoutes(router, requireAdmin);
return router;
}
let pngDataUrl: string;
beforeAll(async () => {
const buffer = await sharp({
create: { width: 4, height: 4, channels: 3, background: "#112233" },
})
.png()
.toBuffer();
pngDataUrl = `data:image/png;base64,${buffer.toString("base64")}`;
});
beforeEach(() => {
state.settings = {};
});
describe("GET /users/branding", () => {
it("succeeds with no auth at all, since the route registers no auth handler", async () => {
// requireAdmin is only ever wired onto PATCH below; GET's own handler chain
// has exactly one entry, proving the public route never runs an admin gate.
const router = buildRouter(fakeRequireAdmin("unauthenticated"));
const handlers = handlersFor(router, "/branding", "get");
expect(handlers).toHaveLength(1);
it("is the only branding route and has no auth handler", () => {
const router = express.Router();
registerBrandingRoutes(router);
const routes = routesOf(router);
const response = await invokeChain(handlers);
expect(routes).toHaveLength(1);
expect(routes[0].path).toBe("/branding");
expect(routes[0].methods).toEqual(["get"]);
expect(routes[0].handlers).toHaveLength(1);
});
it("answers defaults when nothing is set", async () => {
const router = express.Router();
registerBrandingRoutes(router);
const response = await call(routesOf(router)[0].handlers[0]);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
@@ -142,126 +91,19 @@ describe("GET /users/branding", () => {
});
});
it("reflects persisted branding values", async () => {
state.settings.branding_app_name = "Acme Ops";
state.settings.branding_tagline = "Ship it";
state.settings.branding_logo = pngDataUrl;
const router = buildRouter(fakeRequireAdmin("admin"));
it("keeps serving the stored branding of an older install", async () => {
state.settings = {
branding_app_name: "Acme",
branding_tagline: "Ops",
};
const router = express.Router();
registerBrandingRoutes(router);
const response = await call(routesOf(router)[0].handlers[0]);
const response = await invokeChain(handlersFor(router, "/branding", "get"));
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
appName: "Acme Ops",
tagline: "Ship it",
logo: pngDataUrl,
appName: "Acme",
tagline: "Ops",
logo: null,
});
});
});
describe("PATCH /users/branding", () => {
it("rejects an unauthenticated request and persists nothing", async () => {
const router = buildRouter(fakeRequireAdmin("unauthenticated"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "Hijacked" },
);
expect(response.statusCode).toBe(401);
expect(state.settings).toEqual({});
});
it("rejects a non-admin request and persists nothing", async () => {
const router = buildRouter(fakeRequireAdmin("not-admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "Hijacked" },
);
expect(response.statusCode).toBe(403);
expect(state.settings).toEqual({});
});
it("persists only the allowed fields for an admin request", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "Acme Ops", tagline: "Ship it", logo: pngDataUrl },
);
expect(response.statusCode).toBe(200);
expect(state.settings.branding_app_name).toBe("Acme Ops");
expect(state.settings.branding_tagline).toBe("Ship it");
expect(state.settings.branding_logo).toMatch(/^data:image\/png;base64,/);
expect(response.body).toMatchObject({
appName: "Acme Ops",
tagline: "Ship it",
});
});
it("rejects unknown fields without writing anything", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ theme: "dark" },
);
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "BRANDING_SETTINGS_UNKNOWN_FIELD",
});
expect(state.settings).toEqual({});
});
it("rejects an app name over the length limit", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ appName: "x".repeat(61) },
);
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "BRANDING_SETTINGS_INVALID",
field: "appName",
});
expect(state.settings).toEqual({});
});
it("rejects malformed/non-raster logo data without persisting it", async () => {
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{
logo: `data:image/png;base64,${Buffer.from("not an image").toString("base64")}`,
},
);
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
code: "BRANDING_SETTINGS_INVALID",
field: "logo",
});
expect(state.settings).toEqual({});
});
it("clears the logo when patched with null", async () => {
state.settings.branding_logo = pngDataUrl;
const router = buildRouter(fakeRequireAdmin("admin"));
const response = await invokeChain(
handlersFor(router, "/branding", "patch"),
{ logo: null },
);
expect(response.statusCode).toBe(200);
expect(state.settings.branding_logo).toBeUndefined();
expect(response.body).toMatchObject({ logo: null });
});
});
@@ -1,149 +1,17 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../utils/logger.js", () => ({
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const {
BRANDING_DEFAULT_APP_NAME,
BRANDING_LOGO_MAX_BYTES,
parseBrandingAppName,
parseBrandingLogoDataUrl,
parseBrandingTagline,
resolveBrandingSettings,
} = await import("../../../database/routes/branding-settings.js");
const { BRANDING_DEFAULT_APP_NAME, resolveBrandingSettings } =
await import("../../../database/routes/branding-settings.js");
function settingSource(values: Record<string, string>) {
return { get: async (key: string) => values[key] ?? null };
}
async function toDataUrl(
buffer: Buffer,
mime: "image/png" | "image/jpeg" | "image/webp",
): Promise<string> {
return `data:${mime};base64,${buffer.toString("base64")}`;
}
let pngBuffer: Buffer;
let jpegBuffer: Buffer;
let webpBuffer: Buffer;
let oversizedPngBuffer: Buffer;
beforeAll(async () => {
const image = sharp({
create: { width: 4, height: 4, channels: 3, background: "#336699" },
});
pngBuffer = await image.clone().png().toBuffer();
jpegBuffer = await image.clone().jpeg().toBuffer();
webpBuffer = await image.clone().webp().toBuffer();
oversizedPngBuffer = await sharp({
create: { width: 1200, height: 1200, channels: 4, background: "#abcdef" },
})
.png({ compressionLevel: 0 })
.toBuffer();
});
describe("parseBrandingAppName", () => {
it("trims and accepts a normal name", () => {
expect(parseBrandingAppName(" My Company ")).toBe("My Company");
});
it("falls back to the default when cleared", () => {
expect(parseBrandingAppName("")).toBe(BRANDING_DEFAULT_APP_NAME);
expect(parseBrandingAppName(" ")).toBe(BRANDING_DEFAULT_APP_NAME);
});
it("rejects names over 60 characters", () => {
expect(parseBrandingAppName("x".repeat(61))).toBeNull();
});
it("rejects non-string values", () => {
expect(parseBrandingAppName(42)).toBeNull();
expect(parseBrandingAppName(null)).toBeNull();
});
});
describe("parseBrandingTagline", () => {
it("trims and accepts a normal tagline", () => {
expect(parseBrandingTagline(" Ship faster ")).toBe("Ship faster");
});
it("accepts an empty tagline (means: use the default)", () => {
expect(parseBrandingTagline("")).toBe("");
expect(parseBrandingTagline(" ")).toBe("");
});
it("rejects taglines over 160 characters", () => {
expect(parseBrandingTagline("x".repeat(161))).toBeNull();
});
it("rejects non-string values", () => {
expect(parseBrandingTagline(42)).toBeNull();
});
});
describe("parseBrandingLogoDataUrl", () => {
it("accepts a PNG data URL and re-encodes it to PNG", async () => {
const result = await parseBrandingLogoDataUrl(
await toDataUrl(pngBuffer, "image/png"),
);
expect(result).not.toBeNull();
expect(result!.startsWith("data:image/png;base64,")).toBe(true);
const decoded = Buffer.from(result!.split(",")[1]!, "base64");
expect(decoded.subarray(1, 4).toString()).toBe("PNG");
});
it("accepts a JPEG data URL and normalizes it to PNG", async () => {
const result = await parseBrandingLogoDataUrl(
await toDataUrl(jpegBuffer, "image/jpeg"),
);
expect(result).not.toBeNull();
expect(result!.startsWith("data:image/png;base64,")).toBe(true);
});
it("accepts a WEBP data URL and normalizes it to PNG", async () => {
const result = await parseBrandingLogoDataUrl(
await toDataUrl(webpBuffer, "image/webp"),
);
expect(result).not.toBeNull();
expect(result!.startsWith("data:image/png;base64,")).toBe(true);
});
it("rejects a non-image/SVG mime prefix outright", async () => {
const svg = Buffer.from('<svg onload="alert(1)"></svg>', "utf8").toString(
"base64",
);
const result = await parseBrandingLogoDataUrl(
`data:image/svg+xml;base64,${svg}`,
);
expect(result).toBeNull();
});
it("rejects a value that isn't a data URL at all", async () => {
expect(await parseBrandingLogoDataUrl("not-a-data-url")).toBeNull();
expect(await parseBrandingLogoDataUrl(undefined)).toBeNull();
});
it("rejects a spoofed data URL whose bytes aren't a real image", async () => {
const fake = Buffer.from("definitely not an image").toString("base64");
const result = await parseBrandingLogoDataUrl(
`data:image/png;base64,${fake}`,
);
expect(result).toBeNull();
});
it("rejects an image that normalizes over the size cap", async () => {
// Sanity check the fixture is actually large enough to exceed the cap.
expect(oversizedPngBuffer.length).toBeGreaterThan(BRANDING_LOGO_MAX_BYTES);
const result = await parseBrandingLogoDataUrl(
await toDataUrl(oversizedPngBuffer, "image/png"),
);
expect(result).toBeNull();
});
});
describe("resolveBrandingSettings", () => {
it("returns built-in defaults when nothing is persisted", async () => {
const settings = await resolveBrandingSettings(settingSource({}));
@@ -169,3 +37,34 @@ describe("resolveBrandingSettings", () => {
});
});
});
describe("branding provider", () => {
it("uses a plugin's branding provider and falls back when it throws", async () => {
const registry = await import("../../../plugins/registry.js");
const provider = async () => ({
appName: " Acme ",
tagline: "Hello",
logo: "data:image/png;base64,AAAA",
});
registry.provide("demo.brandingProvider", provider);
try {
await expect(resolveBrandingSettings(settingSource({}))).resolves.toEqual(
{
appName: "Acme",
tagline: "Hello",
logo: "data:image/png;base64,AAAA",
},
);
registry.revoke("demo.brandingProvider");
registry.provide("demo.brandingProvider", async () => {
throw new Error("broken");
});
await expect(
resolveBrandingSettings(settingSource({ branding_app_name: "Stored" })),
).resolves.toMatchObject({ appName: "Stored" });
} finally {
registry.clearRegistry();
}
});
});
@@ -52,12 +52,6 @@ vi.mock("../../../utils/logger.js", () => {
};
return { authLogger: log, sshLogger: log, databaseLogger: log };
});
vi.mock("../../../database/routes/credential-key-routes.js", () => ({
registerCredentialKeyRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-deploy-routes.js", () => ({
registerCredentialDeployRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-bulk-routes.js", () => ({
registerCredentialBulkRoutes: () => {},
}));
@@ -34,15 +34,12 @@ vi.mock("../../../utils/data-crypto.js", () => ({
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentRecentActivityRepository: () => ({
listByUserId: async () => state.activity,
vi.mock("../../../plugins/activity-provider.js", () => ({
recentActivity: {
list: async () => state.activity,
deleteByUserId: async () => {},
}),
}));
vi.mock("../../../services/recent-activity.js", () => ({
recordRecentActivity: async () => state.recordResult,
record: async () => state.recordResult,
},
}));
const { default: dashboardRoutes } =
@@ -136,6 +136,22 @@ describe("isNativeTokenExportRequest", () => {
});
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);
@@ -3,7 +3,7 @@ import {
importedHostUsername,
notifyImportedHosts,
parseSSHConfig,
} from "../../../database/routes/host-bulk-routes.js";
} from "../../../hosts/import/host-import.js";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
describe("parseSSHConfig", () => {
@@ -1,5 +1,5 @@
import { beforeEach, expect, it, vi } from "vitest";
import type { Router, RequestHandler, Request, Response } from "express";
import type { RequestHandler, Request, Response } from "express";
const mocks = vi.hoisted(() => ({
create: vi.fn(),
@@ -35,23 +35,35 @@ vi.mock("../../../database/repositories/factory.js", () => ({
findHostsByUserId: mocks.list,
}),
}));
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
import {
ImportError,
importResponse,
runBulkImport,
runSshConfigImport,
} from "../../../hosts/import/host-import.js";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
let handler: RequestHandler;
let sshConfigHandler: RequestHandler;
const router = {
post: (path: string, ...handlers: RequestHandler[]) => {
if (path === "/bulk-import") handler = handlers.at(-1)!;
if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!;
},
put: () => {},
patch: () => {},
delete: () => {},
} as unknown as Router;
const pass: RequestHandler = (_req, _res, next) => next();
registerHostBulkRoutes(router, pass, pass, pass, pass);
// The same wrapping the plugin's routes give the shared import code.
const wrap =
(run: typeof runBulkImport): RequestHandler =>
async (req, res) => {
try {
const body = (req as unknown as { body: never }).body;
res.json(
importResponse(
await run((req as unknown as { userId: string }).userId, body),
),
);
} catch (error) {
if (error instanceof ImportError) {
res.status(400).json({ error: error.message });
return;
}
throw error;
}
};
const handler = wrap(runBulkImport);
const sshConfigHandler = wrap(runSshConfigImport as typeof runBulkImport);
const host = {
ip: "192.0.2.1",
port: 22,
@@ -76,12 +88,7 @@ it.each([false, true])(
"carries plugin host settings through export selection and import (overwrite=%s)",
async (overwrite) => {
if (overwrite) mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
const payload = buildExportPayload(
{ hosts: [host] },
null,
new Set(["featureFlags", "advanced"]),
false,
);
const payload = { hosts: [host] };
expect(payload.hosts[0]).toMatchObject(host);
const json = vi.fn();
await handler(
@@ -130,12 +137,7 @@ it.each([20, "20"])(
{ ...host, ip: "192.0.2.2", exportId: 20 },
],
};
const payload = buildExportPayload(
raw,
null,
new Set(["jumpHosts"]),
false,
);
const payload = raw;
const res = await importHosts(payload.hosts);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: 2, failed: 0 }),
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express, { type RequestHandler } from "express";
import request from "supertest";
const state = vi.hoisted(() => ({
updates: [] as Array<{ ids: number[]; update: Record<string, unknown> }>,
renamed: 0,
folderHosts: [] as Array<{ id: number }>,
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
pluginLogger: { warn: vi.fn() },
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { batched: (fn: RequestHandler) => fn },
}));
vi.mock("../../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
}));
vi.mock("../../../hosts/defaults/recompute.js", () => ({
recompute: async () => ({ changedHostIds: [] }),
}));
vi.mock("../../../hosts/defaults/service.js", () => ({
folderCredentialIds: async () => [],
setFolderCredentialDefault: async () => {},
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
setHostPluginEnabled: async () => true,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostRepository: () => ({
listBulkUpdateState: async (_userId: string, ids: number[]) =>
ids.map((id) => ({ id })),
updateManyForUser: async (
_userId: string,
ids: number[],
update: Record<string, unknown>,
) => {
state.updates.push({ ids, update });
},
}),
createCurrentHostFolderRepository: () => ({
listHostsInFolder: async () => state.folderHosts,
renameFolder: async () => {
state.renamed++;
return { updatedHosts: 1, updatedCredentials: 0 };
},
}),
createCurrentFolderAccessRepository: () => ({
renameFolder: async () => {},
}),
}));
const { registerHostBulkRoutes } =
await import("../../../database/routes/host-bulk-routes.js");
const { registerHostFolderRoutes } =
await import("../../../database/routes/host-folder-routes.js");
const { clearHostLocks, provideHostLocks } =
await import("../../../hosts/locks.js");
const pass: RequestHandler = (req, _res, next) => {
(req as unknown as { userId: string }).userId = "owner";
next();
};
function app() {
const router = express.Router();
registerHostBulkRoutes(router, pass, pass, pass);
registerHostFolderRoutes(router, {
authenticateJWT: pass,
requireViewPermission: pass,
requireEditPermission: pass,
requireDeletePermission: pass,
requireCredentialEditPermission: pass,
requireDataAccess: pass,
});
const server = express();
server.use(express.json());
server.use(router);
return server;
}
beforeEach(() => {
state.updates = [];
state.renamed = 0;
state.folderHosts = [];
provideHostLocks("cfg", "Config", {
isLocked: (hostId) => hostId === 2,
note: () => null,
allowManualCreate: () => true,
});
});
afterEach(() => clearHostLocks());
describe("routes on locked hosts", () => {
it("still lets a user pin a locked host", async () => {
await request(app())
.patch("/bulk-update")
.send({ hostIds: [1, 2], updates: { pin: true } })
.expect(200);
expect(state.updates).toEqual([{ ids: [1, 2], update: { pin: true } }]);
});
it("refuses a bulk change that touches a locked host", async () => {
const res = await request(app())
.patch("/bulk-update")
.send({ hostIds: [1, 2], updates: { folder: "Moved" } })
.expect(403);
expect(res.body).toMatchObject({ code: "HOST_LOCKED" });
expect(state.updates).toEqual([]);
});
it("refuses renaming a folder that holds a locked host", async () => {
state.folderHosts = [{ id: 1 }, { id: 2 }];
await request(app())
.put("/folders/rename")
.send({ oldName: "Prod", newName: "Live" })
.expect(403);
expect(state.renamed).toBe(0);
state.folderHosts = [{ id: 1 }];
await request(app())
.put("/folders/rename")
.send({ oldName: "Prod", newName: "Live" })
.expect(200);
expect(state.renamed).toBe(1);
});
});
@@ -15,7 +15,8 @@ const service = vi.hoisted(() => ({
get: vi.fn(),
check: vi.fn(),
refresh: vi.fn(),
retimeAll: vi.fn(),
getIntervalSeconds: vi.fn(),
setIntervalSeconds: vi.fn(),
}));
vi.mock("../../../utils/logger.js", () => ({
@@ -35,18 +36,8 @@ vi.mock("../../../utils/permission-manager.js", () => ({
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null,
set: async (key: string, value: string) => {
state.settings[key] = value;
},
}),
}));
vi.mock("../../../hosts/status/host-status-service.js", () => ({
DEFAULT_STATUS_INTERVAL: 60,
GLOBAL_STATUS_INTERVAL_KEY: "global_status_check_interval",
hostStatusService: service,
vi.mock("../../../plugins/host-status-provider.js", () => ({
hostStatus: service,
}));
const { registerHostStatusRoutes, parseStatusHostIds } =
@@ -83,6 +74,8 @@ async function call(method: string, path: string, body?: unknown) {
beforeEach(() => {
state.settings = {};
service.getIntervalSeconds.mockResolvedValue(60);
service.setIntervalSeconds.mockResolvedValue(undefined);
state.locked = false;
state.accessible = new Set([1, 2]);
state.statuses = new Map([
@@ -149,14 +142,13 @@ describe("status settings", () => {
});
});
it("saves a new interval and re-times the checks", async () => {
it("saves a new interval through the provider", async () => {
await start();
const { status } = await call("PUT", "/host/status/settings", {
statusCheckInterval: 90,
});
expect(status).toBe(200);
expect(state.settings.global_status_check_interval).toBe("90");
expect(service.retimeAll).toHaveBeenCalledOnce();
expect(service.setIntervalSeconds).toHaveBeenCalledWith(90);
});
it("rejects an interval out of range", async () => {
@@ -0,0 +1,228 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
const state = vi.hoisted(() => ({
userId: "user-1",
admins: new Set(["admin-1"]),
prefs: null as { layoutId: string | null; themeId: string | null } | null,
settings: new Map<string, string>(),
audits: [] as unknown[],
skipped: [] as unknown[],
defaultLookIds: [] as string[],
installed: [] as string[],
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: unknown) => {
state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: express.Request, _res: unknown, next: () => void) => {
(req as unknown as { userId: string }).userId = state.userId;
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserPreferenceRepository: () => ({
findByUserId: async () => state.prefs,
}),
createCurrentUserRepository: () => ({
findById: async (id: string) => ({
id,
username: id,
isAdmin: state.admins.has(id),
}),
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings.get(key) ?? null,
set: async (key: string, value: string) => {
state.settings.set(key, value);
},
delete: async (key: string) => {
state.settings.delete(key);
},
}),
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({
loader: {
get: (id: string) =>
id === "shipped-look"
? {
manifest: {
contributes: {
layouts: [
{ id: "shipped", label: "Shipped", defaultTheme: "ink" },
],
},
},
}
: undefined,
list: () => [
{
manifest: {
contributes: {
layouts: state.installed.map((id) => ({ id, label: id })),
},
},
},
],
skipped: () => state.skipped,
},
}),
}));
vi.mock("../../../plugins/bundled-index.js", () => ({
defaultLookPluginIds: () => state.defaultLookIds,
}));
const { default: lookRoutes } =
await import("../../../database/routes/look.js");
let server: http.Server;
let baseUrl: string;
beforeEach(async () => {
state.userId = "user-1";
state.prefs = null;
state.settings.clear();
state.audits = [];
state.skipped = [];
state.defaultLookIds = [];
state.installed = [];
delete process.env.TERMIX_SAFE_LAYOUT;
const app = express();
app.use(express.json());
app.use("/user-preferences/look", lookRoutes);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
delete process.env.TERMIX_SAFE_LAYOUT;
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function getLook() {
const res = await fetch(`${baseUrl}/user-preferences/look`);
return { status: res.status, body: await res.json() };
}
async function putDefaults(body: unknown) {
const res = await fetch(`${baseUrl}/user-preferences/look/defaults`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
describe("GET /user-preferences/look", () => {
it("is all empty on a fresh install", async () => {
expect((await getLook()).body).toEqual({
layoutId: null,
themeId: null,
defaults: { layout: null, theme: null },
defaultLook: { layout: null, theme: null },
safeLayout: false,
unavailable: [],
});
});
it("returns the user's pick, the admin defaults and the default look", async () => {
state.prefs = { layoutId: "mine", themeId: "nord" };
state.installed = ["mine", "admin-layout"];
state.settings.set("ui.layout", "admin-layout");
state.settings.set("ui.theme", "gruvbox");
state.defaultLookIds = ["shipped-look"];
state.skipped = [
{
id: "broken",
version: "1.0.0",
layouts: ["b"],
themes: [],
error: "x",
},
{ id: "other", version: null, layouts: [], themes: [], error: "y" },
];
process.env.TERMIX_SAFE_LAYOUT = "1";
const { body } = await getLook();
expect(body).toMatchObject({
layoutId: "mine",
themeId: "nord",
defaults: { layout: "admin-layout", theme: "gruvbox" },
defaultLook: { layout: "shipped", theme: "ink" },
safeLayout: true,
});
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
["broken"],
);
});
});
describe("GET /user-preferences/look with an uninstalled layout", () => {
it("drops a choice no installed plugin brings, so the next one applies", async () => {
state.prefs = { layoutId: "deleted", themeId: null };
state.settings.set("ui.layout", "also-deleted");
state.installed = ["kept"];
const { body } = await getLook();
expect(body.layoutId).toBeNull();
expect(body.defaults.layout).toBeNull();
});
it("keeps a choice whose plugin failed to load, for the repair view", async () => {
state.prefs = { layoutId: "b", themeId: null };
state.skipped = [
{
id: "broken",
version: "1.0.0",
layouts: ["b"],
themes: [],
error: "x",
},
];
expect((await getLook()).body.layoutId).toBe("b");
});
});
describe("PUT /user-preferences/look/defaults", () => {
it("is for admins only", async () => {
const { status } = await putDefaults({ layout: "x" });
expect(status).toBe(403);
expect(state.settings.size).toBe(0);
});
it("sets and clears the defaults, and audits it", async () => {
state.userId = "admin-1";
let res = await putDefaults({ layout: "fancy", theme: "nord" });
expect(res).toEqual({
status: 200,
body: { layout: "fancy", theme: "nord" },
});
res = await putDefaults({ layout: null });
expect(res.body).toEqual({ layout: null, theme: "nord" });
expect(state.audits).toHaveLength(2);
expect(state.audits[0]).toMatchObject({ action: "update_default_look" });
});
it("refuses something that is not an id", async () => {
state.userId = "admin-1";
expect((await putDefaults({ theme: "../evil" })).status).toBe(400);
expect((await putDefaults({ layout: 5 })).status).toBe(400);
expect(state.settings.size).toBe(0);
});
});
@@ -169,6 +169,7 @@ const manageMock = vi.hoisted(() => ({
updatePlugin: vi.fn(),
updateAllPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
restoreBundledPlugin: vi.fn(),
setPluginOptions: vi.fn(),
setAllPluginChannels: vi.fn(),
getPluginDataSummary: vi.fn(),
@@ -433,6 +434,7 @@ describe("plugins route", () => {
"name",
"optionalDependencies",
"state",
"themeVersion",
"version",
].sort(),
);
@@ -624,10 +626,12 @@ describe("plugins route", () => {
contributes: null,
dependencies: {},
optionalDependencies: {},
defaultLook: false,
frontend: false,
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
});
});
@@ -1138,6 +1142,35 @@ describe("plugins route", () => {
expect(manageMock.uninstallPlugin).toHaveBeenCalledWith("docker");
});
it("restores the bundled copy", async () => {
manageMock.restoreBundledPlugin.mockResolvedValue({
id: "docker",
version: "1.0.0",
state: "active",
});
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "docker",
version: "1.0.0",
state: "active",
});
expect(manageMock.restoreBundledPlugin).toHaveBeenCalledWith("docker");
});
it("answers 409 when a plugin has no bundled copy", async () => {
manageMock.restoreBundledPlugin.mockRejectedValue(
new PluginManageError(
"This plugin has no bundled copy",
409,
"NO_BUNDLED_COPY",
),
);
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("NO_BUNDLED_COPY");
});
it("refuses changes on a linked desktop", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
const res = await post("/plugins/docker/install");
@@ -24,14 +24,12 @@ describe("RBAC coverage for split host routers", () => {
it("gates bulk mutations by action and data-access permissions", () => {
const router = express.Router();
const authenticate = middleware();
const requireCreate = middleware();
const requireEdit = middleware();
const requireDataAccess = middleware();
registerHostBulkRoutes(
router,
authenticate,
requireCreate,
requireEdit,
requireDataAccess,
);
@@ -46,14 +44,6 @@ describe("RBAC coverage for split host routers", () => {
requireEdit,
requireDataAccess,
]);
for (const path of ["/bulk-import", "/ssh-config-import"]) {
expect(handlers(router, "post", path).slice(0, 4)).toEqual([
authenticate,
requireCreate,
requireEdit,
requireDataAccess,
]);
}
});
it("gates folder reads and mutations with their matching permissions", () => {
@@ -28,11 +28,18 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
createCurrentSettingsRepository: () => ({}),
createCurrentSshCredentialUsageRepository: deletingRepo("usage"),
createCurrentRecentActivityRepository: deletingRepo("activity"),
createCurrentHostRepository: deletingRepo("hosts"),
createCurrentCredentialRepository: deletingRepo("credentials"),
}));
vi.mock("../../../plugins/activity-provider.js", () => ({
recentActivity: {
deleteByUserId: async (userId: string) => {
calls.deletedFor.push(`activity:${userId}`);
},
},
}));
vi.mock("../../../plugins/events.js", () => ({
pluginEvents: {
emit: (_topic: string, payload: { userId: string }) => {
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
const state = vi.hoisted(() => ({
row: null as Record<string, unknown> | null,
upserts: [] as Record<string, unknown>[],
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: express.Request, _res: unknown, next: () => void) => {
(req as unknown as { userId: string }).userId = "user-1";
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserPreferenceRepository: () => ({
findByUserId: async () => state.row,
upsert: async (_userId: string, updates: Record<string, unknown>) => {
state.upserts.push(updates);
state.row = { ...(state.row ?? {}), ...updates };
},
}),
}));
vi.mock("../../../database/routes/keybinding-validation.js", () => ({
isValidKeybinding: () => true,
}));
const { default: routes } =
await import("../../../database/routes/user-preferences.js");
let server: http.Server;
let baseUrl: string;
beforeEach(async () => {
state.row = null;
state.upserts = [];
const app = express();
app.use(express.json());
app.use("/user-preferences", routes);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function put(body: unknown) {
const res = await fetch(`${baseUrl}/user-preferences`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
describe("user preferences: layout and theme", () => {
it("reads null for both until chosen", async () => {
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
expect(body).toMatchObject({ layoutId: null, themeId: null });
});
it("saves a layout and theme id, and null to follow the default", async () => {
expect((await put({ layoutId: "fancy", themeId: "nord" })).status).toBe(
200,
);
expect(state.upserts[0]).toMatchObject({
layoutId: "fancy",
themeId: "nord",
});
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
expect(body).toMatchObject({ layoutId: "fancy", themeId: "nord" });
await put({ layoutId: null });
expect(state.upserts[1]).toMatchObject({ layoutId: null });
expect(state.upserts[1]).not.toHaveProperty("themeId");
});
it("refuses anything that is not an id", async () => {
for (const bad of ["Fancy", "../x", "a", 7]) {
expect((await put({ themeId: bad })).status).toBe(400);
}
expect(state.upserts).toHaveLength(0);
});
});
@@ -26,6 +26,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null,
getBoolean: async (key: string, fallback: boolean) =>
key in state.settings ? state.settings[key] === "true" : fallback,
set: async (key: string, value: string) => {
state.settings[key] = value;
},
@@ -39,6 +41,12 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
vi.mock("../../../tls/tls-service.js", () => ({
getTlsConfig: () => ({ enabled: true, port: 8443 }),
}));
const { resetHttpsPolicyForTests } =
await import("../../../tls/https-policy.js");
const { registerUserSettingsRoutes } =
await import("../../../database/routes/user-settings-routes.js");
@@ -59,6 +67,7 @@ function app() {
beforeEach(() => {
state.settings = {};
state.admin = true;
resetHttpsPolicyForTests();
});
describe("/users/host-defaults", () => {
@@ -77,3 +86,35 @@ describe("/users/host-defaults", () => {
expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true);
});
});
describe("/users/https-settings", () => {
it("is off by default and saves a change", async () => {
const read = await request(app()).get("/users/https-settings");
expect(read.body).toEqual({ forceHttps: false, tlsEnabled: true });
const write = await request(app())
.patch("/users/https-settings")
.send({ forceHttps: true });
expect(write.status).toBe(200);
expect(state.settings.force_https).toBe("true");
const after = await request(app()).get("/users/https-settings");
expect(after.body.forceHttps).toBe(true);
});
it("is admin only and checks the value", async () => {
const bad = await request(app())
.patch("/users/https-settings")
.send({ forceHttps: "yes" });
expect(bad.status).toBe(400);
state.admin = false;
expect((await request(app()).get("/users/https-settings")).status).toBe(
403,
);
const write = await request(app())
.patch("/users/https-settings")
.send({ forceHttps: true });
expect(write.status).toBe(403);
expect(state.settings.force_https).toBeUndefined();
});
});
@@ -25,6 +25,10 @@ const issueSocketTicket = vi.fn(
async (userId: string, sessionId: string) => `ticket:${userId}:${sessionId}`,
);
const issueApiKeySocketTicket = vi.fn(
async (userId: string, keyId: string) => `key-ticket:${userId}:${keyId}`,
);
const authenticate: RequestHandler = (req, _res, next) => {
Object.assign(req, {
userId: "u1",
@@ -38,7 +42,10 @@ function app() {
const router = express.Router();
registerUserSocketTicketRoutes(router, {
authenticateJWT: authenticate,
authManager: { issueSocketTicket } as unknown as AuthManager,
authManager: {
issueSocketTicket,
issueApiKeySocketTicket,
} as unknown as AuthManager,
});
const server = express();
server.use(cookieParser());
@@ -50,6 +57,7 @@ beforeEach(() => {
state.sessionId = "s1";
state.apiKeyId = undefined;
issueSocketTicket.mockClear();
issueApiKeySocketTicket.mockClear();
});
describe("POST /users/ws-ticket", () => {
@@ -98,3 +106,23 @@ describe("POST /users/ws-ticket", () => {
expect(issueSocketTicket).not.toHaveBeenCalled();
});
});
describe("POST /users/ws-ticket/api-key", () => {
it("gives an API key a ticket as its user", async () => {
state.sessionId = undefined;
state.apiKeyId = "key-1";
const res = await request(app()).post("/users/ws-ticket/api-key");
expect(res.status).toBe(200);
expect(res.body.ticket).toBe("key-ticket:u1:key-1");
expect(res.headers["cache-control"]).toBe("no-store");
});
it("refuses a login session, which has its own ticket", async () => {
const res = await request(app()).post("/users/ws-ticket/api-key");
expect(res.status).toBe(401);
expect(issueApiKeySocketTicket).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ const main = fs.readFileSync(
"utf8",
);
const shell = fs.readFileSync(
new URL("../../../ui/AppShell.tsx", import.meta.url),
new URL("../../../ui/shell/controller/use-tabs.ts", import.meta.url),
"utf8",
);
@@ -1,86 +0,0 @@
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const { createC2SAuthBridge } = require("../../../../electron/c2s-auth.cjs");
function setup() {
const sender = Object.assign(new EventEmitter(), {
send: vi.fn(),
isDestroyed: () => false,
});
const ws = Object.assign(new EventEmitter(), {
readyState: 1,
send: vi.fn(),
close: vi.fn(() => {
ws.readyState = 3;
ws.emit("close");
}),
});
let answer!: (
event: { sender: unknown },
id: string,
value: unknown,
) => boolean;
const attach = createC2SAuthBridge(
{
handle: (_name: string, fn: typeof answer) => {
answer = fn;
},
},
() => ({ isDestroyed: () => false, webContents: sender }),
);
attach(ws, "dynamic");
const prompt = () =>
ws.emit(
"message",
Buffer.from(
JSON.stringify({
type: "auth-prompt",
requestId: "server-challenge",
request: { kind: "totp", prompt: "Code:", retry: false },
}),
),
false,
);
return { sender, ws, answer, prompt };
}
afterEach(() => vi.useRealTimers());
describe("desktop tunnel prompt bridge", () => {
it("binds answers to the renderer and challenge, and rejects replay", () => {
const { sender, ws, answer, prompt } = setup();
prompt();
const id = sender.send.mock.calls[0][1].id;
expect(answer({ sender: {} }, id, "123456")).toBe(false);
expect(answer({ sender }, "unknown", "123456")).toBe(false);
expect(answer({ sender }, id, { secret: "bad" })).toBe(false);
expect(ws.send).not.toHaveBeenCalled();
expect(answer({ sender }, id, "123456")).toBe(true);
expect(JSON.parse(ws.send.mock.calls[0][0])).toEqual({
type: "auth-response",
requestId: "server-challenge",
answer: "123456",
});
expect(answer({ sender }, id, "again")).toBe(false);
expect(sender.listenerCount("destroyed")).toBe(0);
ws.close();
});
it.each(["close", "reload", "timeout"])(
"clears a pending dialog on %s",
(reason) => {
vi.useFakeTimers();
const { sender, ws, answer, prompt } = setup();
prompt();
const id = sender.send.mock.calls[0][1].id;
if (reason === "close") ws.close();
else if (reason === "reload") sender.emit("did-start-loading");
else vi.advanceTimersByTime(60_000);
expect(sender.send).toHaveBeenLastCalledWith("c2s-auth-prompt", {
id,
closed: true,
});
expect(answer({ sender }, id, "late")).toBe(false);
expect(ws.close).toHaveBeenCalled();
},
);
});
@@ -1,238 +0,0 @@
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import { describe, expect, it, vi } from "vitest";
const main = readFileSync(
new URL("../../../../electron/main.cjs", import.meta.url),
"utf8",
);
const section = (from: string, to: string) =>
main.slice(main.indexOf(from), main.indexOf(to, main.indexOf(from)));
function setup() {
const sockets: Socket[] = [];
class Socket extends EventEmitter {
readyState = 1;
send = vi.fn();
close = vi.fn(() => {
if (this.readyState === 3) return;
this.readyState = 3;
this.emit("close");
});
constructor() {
super();
sockets.push(this);
queueMicrotask(() => this.emit("open"));
}
ready() {
this.emit(
"message",
Buffer.from(JSON.stringify({ type: "ready" })),
false,
);
}
}
const runtimes = new Map();
let incoming!: (socket: EventEmitter) => void;
const server = Object.assign(new EventEmitter(), {
listen: (_options: unknown, ready: () => void) => ready(),
close: vi.fn((done?: () => void) => done?.()),
});
const headers = vi.fn(async () => ({}));
const dynamic = vi.fn();
const context = vm.createContext({
setTimeout,
clearTimeout,
WebSocket: Socket,
crypto: { randomUUID: () => "runtime-token" },
net: {
createServer: (callback: typeof incoming) => {
incoming = callback;
return server;
},
},
c2sTunnelRuntimes: runtimes,
normalizeC2STunnelAddresses: (t: unknown) => t,
resolveC2SRemoteSourceHost: async (t: unknown) => t,
getC2STunnelName: (t: { name: string }) => t.name,
getC2SLocalAddress: () => "127.0.0.1",
getC2SRemoteAddress: () => "127.0.0.1",
getC2SRelayUrl: () => "wss://server/relay",
getC2SRelayHeaders: headers,
getWebSocketOptions: (_url: string, options: unknown) => options,
attachC2SAuth: vi.fn(),
keepC2SSocketAlive: vi.fn(),
logToFile: vi.fn(),
emitC2STunnelStatuses: vi.fn(),
checkLocalPortAvailable: async () => ({ available: true }),
handleC2SDynamicConnection: dynamic,
setC2STunnelStatus: (name: string, status: unknown) => {
runtimes.get(name).status = status;
},
setC2STunnelError: (name: string, error: string) => {
runtimes.get(name).status = { connected: false, reason: error };
},
createC2SFailure: (error: string) => ({ success: false, error }),
normalizeC2SErrorMessage: (message: string) => message,
C2S_REMOTE_SESSION_EXPIRED_ERROR: "expired",
});
vm.runInContext(
section("async function openC2SRelay(", "async function testC2SRelay(") +
section("async function testC2SRelay(", "async function testC2STunnel(") +
section("async function startC2STunnel(", "function stopAllC2STunnels("),
context,
);
return {
context,
runtimes,
sockets,
server,
headers,
dynamic,
incoming: () => incoming,
};
}
describe("client tunnel runtime authentication", () => {
it("holds preflight open, shares its runtime identity and token, and releases it when stopped", async () => {
const { context, runtimes, sockets, server, headers, dynamic, incoming } =
setup();
const tunnel = {
name: "socks",
mode: "dynamic",
sourceHostId: 7,
sourcePort: 1080,
};
await context.startC2STunnel(tunnel, 0, "local-auth");
await vi.waitFor(() => expect(sockets).toHaveLength(1));
const premature = Object.assign(new EventEmitter(), { destroy: vi.fn() });
incoming()(premature);
expect(premature.destroy).toHaveBeenCalledOnce();
expect(headers).toHaveBeenCalledWith("wss://server/relay", "local-auth");
expect(JSON.parse(sockets[0].send.mock.calls[0][0])).toMatchObject({
keepAlive: true,
tunnelConfig: { sessionId: "runtime-token" },
});
sockets[0].ready();
await vi.waitFor(() =>
expect(runtimes.get("socks").status.connected).toBe(true),
);
expect(sockets[0].close).not.toHaveBeenCalled();
const client = Object.assign(new EventEmitter(), { destroy: vi.fn() });
incoming()(client);
expect(dynamic).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: "runtime-token" }),
client,
"local-auth",
);
await context.stopC2STunnel("socks");
expect(sockets[0].close).toHaveBeenCalledOnce();
expect(client.destroy).toHaveBeenCalledOnce();
expect(server.close).toHaveBeenCalledOnce();
expect(runtimes.size).toBe(0);
});
it("does not retain a socket for a standalone test or a stopped runtime", async () => {
const { context, sockets } = setup();
const test = context.testC2SRelay({ name: "test" });
await vi.waitFor(() => expect(sockets).toHaveLength(1));
sockets[0].ready();
expect(await test).toEqual({ success: true });
expect(sockets[0].close).toHaveBeenCalledOnce();
expect(
await context.testC2SRelay({ name: "stopped" }, null, null, null, {}),
).toEqual({ success: false, error: "Tunnel stopped" });
expect(sockets).toHaveLength(1);
});
it("keeps the relay's reason when it closes after an error", async () => {
const { context, runtimes, sockets } = setup();
const runtime = { status: { connected: true } };
runtimes.set("web", runtime);
const test = context.testC2SRelay(
{ name: "web" },
"127.0.0.1",
8443,
null,
runtime,
);
await vi.waitFor(() => expect(sockets).toHaveLength(1));
sockets[0].emit(
"message",
Buffer.from(
JSON.stringify({
type: "error",
error: "Target 127.0.0.1:8443 refused",
}),
),
false,
);
sockets[0].close();
expect(await test).toMatchObject({ success: false });
expect(runtimes.get("web").status.reason).toBe(
"Target 127.0.0.1:8443 refused",
);
});
it("keeps the tunnel connected when one of its connections fails", async () => {
const { context, runtimes, sockets } = setup();
runtimes.set("web", { status: { connected: true, status: "CONNECTED" } });
const local = () =>
Object.assign(new EventEmitter(), {
destroy: vi.fn(),
write: vi.fn(),
});
const first = local();
await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, first);
await vi.waitFor(() => expect(sockets).toHaveLength(1));
first.emit("error", new Error("ECONNRESET"));
sockets[0].emit(
"message",
Buffer.from(JSON.stringify({ type: "error", error: "refused" })),
false,
);
expect(runtimes.get("web").status.connected).toBe(true);
await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, local());
await vi.waitFor(() => expect(sockets).toHaveLength(2));
sockets[1].emit(
"message",
Buffer.from(JSON.stringify({ type: "error", error: "expired" })),
false,
);
expect(runtimes.get("web").status).toMatchObject({
connected: false,
reason: "expired",
});
});
});
describe("client tunnel socket keepalive", () => {
it("pings an open socket until it closes", () => {
vi.useFakeTimers();
try {
const context = vm.createContext({
setInterval,
clearInterval,
WebSocket: { OPEN: 1 },
});
vm.runInContext(
section("function keepC2SSocketAlive(", "// C2S (client-to-server)"),
context,
);
const ws = Object.assign(new EventEmitter(), {
readyState: 1,
ping: vi.fn(),
});
context.keepC2SSocketAlive(ws);
vi.advanceTimersByTime(50_000);
expect(ws.ping).toHaveBeenCalledTimes(2);
ws.emit("close");
vi.advanceTimersByTime(50_000);
expect(ws.ping).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,164 @@
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { createEmbeddedViews, cleanBounds } = createRequire(import.meta.url)(
"../../../../electron/embedded-view.cjs",
);
const views: FakeView[] = [];
class FakeView {
webContents = Object.assign(new EventEmitter(), {
setWindowOpenHandler: vi.fn(),
loadURL: vi.fn().mockResolvedValue(undefined),
isDestroyed: () => false,
close: vi.fn(),
});
setBounds = vi.fn();
setVisible = vi.fn();
constructor(public options: { webPreferences: Record<string, unknown> }) {
views.push(this);
}
}
const bounds = { x: 10.4, y: 20.6, width: 300, height: 200 };
let contentView: {
addChildView: ReturnType<typeof vi.fn>;
removeChildView: ReturnType<typeof vi.fn>;
};
let session: EventEmitter & Record<string, ReturnType<typeof vi.fn>>;
let manager: ReturnType<typeof createEmbeddedViews>;
beforeEach(() => {
views.length = 0;
contentView = { addChildView: vi.fn(), removeChildView: vi.fn() };
session = Object.assign(new EventEmitter(), {
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
clearStorageData: vi.fn().mockResolvedValue(undefined),
clearCache: vi.fn().mockResolvedValue(undefined),
closeAllConnections: vi.fn().mockResolvedValue(undefined),
webRequest: { onBeforeRequest: vi.fn() },
});
manager = createEmbeddedViews({
WebContentsView: FakeView,
session: { fromPartition: vi.fn(() => session) },
getMainWindow: () => ({ contentView, isDestroyed: () => false }),
});
});
describe("embedded views", () => {
it("opens a locked down view at the given bounds", () => {
manager.open({ id: "a", url: "https://pve.example:8006/", bounds });
const [view] = views;
expect(view.options.webPreferences).toMatchObject({
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webviewTag: false,
});
expect(contentView.addChildView).toHaveBeenCalledWith(view);
expect(view.setBounds).toHaveBeenCalledWith({
x: 10,
y: 21,
width: 300,
height: 200,
});
expect(view.webContents.loadURL).toHaveBeenCalledWith(
"https://pve.example:8006/",
);
const handler = session.setPermissionRequestHandler.mock.calls[0][0];
const allow = vi.fn();
handler(null, "media", allow);
expect(allow).toHaveBeenCalledWith(false);
});
it("refuses urls that are not plain http or https", () => {
expect(() =>
manager.open({ id: "a", url: "file:///etc/passwd", bounds }),
).toThrow();
expect(() =>
manager.open({ id: "a", url: "https://u:p@host/", bounds }),
).toThrow();
});
it("moves and hides an open view, and closes it", () => {
manager.open({ id: "a", url: "https://x.example/", bounds });
manager.setBounds("a", { x: 1, y: 2, width: 3, height: 4 });
manager.setVisible("a", false);
const [view] = views;
expect(view.setBounds).toHaveBeenLastCalledWith({
x: 1,
y: 2,
width: 3,
height: 4,
});
expect(view.setVisible).toHaveBeenLastCalledWith(false);
manager.destroy("a");
expect(contentView.removeChildView).toHaveBeenCalledWith(view);
expect(view.webContents.close).toHaveBeenCalled();
expect(session.clearStorageData).toHaveBeenCalled();
});
it("reuses a view for the same url and replaces it for a new one", () => {
manager.open({ id: "a", url: "https://x.example/", bounds });
manager.open({ id: "a", url: "https://x.example/", bounds });
expect(views).toHaveLength(1);
manager.open({ id: "a", url: "https://y.example/", bounds });
expect(views).toHaveLength(2);
expect(contentView.removeChildView).toHaveBeenCalledWith(views[0]);
});
it("accepts an invalid certificate only for its own origin and only when asked", () => {
manager.open({
id: "a",
url: "https://x.example/",
ignoreCert: true,
bounds,
});
manager.open({ id: "b", url: "https://z.example/", bounds });
const check = (index: number, url: string) => {
const event = { preventDefault: vi.fn() };
const callback = vi.fn();
const handled = manager.handleCertificateError(
event,
views[index].webContents,
url,
callback,
);
return { handled, allowed: callback.mock.calls[0]?.[0] };
};
expect(check(0, "https://x.example/page")).toEqual({
handled: true,
allowed: true,
});
expect(check(0, "https://other.example/")).toEqual({
handled: true,
allowed: false,
});
expect(check(1, "https://z.example/")).toEqual({
handled: true,
allowed: false,
});
expect(
manager.handleCertificateError({}, {}, "https://x.example/", vi.fn()),
).toBe(false);
});
it("closes everything", () => {
manager.open({ id: "a", url: "https://x.example/", bounds });
manager.open({ id: "b", url: "https://y.example/", bounds });
manager.destroyAll();
expect(contentView.removeChildView).toHaveBeenCalledTimes(2);
});
it("keeps bounds as whole, non negative numbers", () => {
expect(cleanBounds({ x: -5, y: "x", width: 9.5, height: 1 })).toEqual({
x: 0,
y: 0,
width: 10,
height: 1,
});
});
});
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const linkedServer = require("../../../../electron/linked-server.cjs") as {
setLinkedServer: (config: unknown) => void;
clientCertOptionsFor: (url: string) => Record<string, unknown>;
};
const source = fs.readFileSync(
@@ -14,7 +15,7 @@ const source = fs.readFileSync(
);
const tls = source.slice(
source.indexOf("const INSECURE_MODE_VALUES"),
source.indexOf("function httpFetch("),
source.indexOf('if (process.platform === "linux") {'),
);
const ORIGIN = "https://termix.example";
@@ -30,10 +31,7 @@ function load(link: Record<string, unknown> | null) {
vm.runInContext(tls, context);
return context as unknown as {
isInvalidCertificateAllowedForUrl: (url: string) => boolean;
getWebSocketOptions: (
url: string,
options?: Record<string, unknown>,
) => Record<string, unknown>;
getTlsVerificationOptions: (url: string) => Record<string, unknown>;
};
}
@@ -41,8 +39,7 @@ describe("linked server certificate exception", () => {
it("covers WebSockets to the linked server", () => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
expect(main.isInvalidCertificateAllowedForUrl(TERMINAL)).toBe(true);
expect(main.getWebSocketOptions(TERMINAL, { headers: {} })).toEqual({
headers: {},
expect(main.getTlsVerificationOptions(TERMINAL)).toEqual({
rejectUnauthorized: false,
});
});
@@ -58,8 +55,31 @@ describe("linked server certificate exception", () => {
it("keeps verifying when the link does not allow it", () => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: false });
expect(main.getWebSocketOptions(TERMINAL)).toEqual({
expect(main.getTlsVerificationOptions(TERMINAL)).toEqual({
rejectUnauthorized: true,
});
});
});
describe("linked server client certificate", () => {
const clientCert = {
pfx: Buffer.from("pfx-bytes").toString("base64"),
passphrase: "pw",
};
it("hands the PFX to TLS made for the linked origin only", () => {
linkedServer.setLinkedServer({ origin: ORIGIN, clientCert });
expect(linkedServer.clientCertOptionsFor(TERMINAL)).toEqual({
pfx: Buffer.from("pfx-bytes"),
passphrase: "pw",
});
expect(
linkedServer.clientCertOptionsFor("https://elsewhere.example/"),
).toEqual({});
});
it("offers nothing when the link has no certificate", () => {
linkedServer.setLinkedServer({ origin: ORIGIN });
expect(linkedServer.clientCertOptionsFor(TERMINAL)).toEqual({});
});
});
@@ -1,54 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { describe, expect, it } from "vitest";
const source = fs.readFileSync(
new URL("../../../../electron/main.cjs", import.meta.url),
"utf8",
);
const collector = source.slice(
source.indexOf("function collectLocalFilesFromPath("),
source.indexOf('ipcMain.handle("get-local-home-directory"'),
);
const handler = source.slice(
source.indexOf('ipcMain.handle("collect-local-files"'),
source.indexOf('ipcMain.handle("create-local-folder"'),
);
describe("local collection limit", () => {
it.each([9999, 10000, 10001])(
"reports truncation only when %s files exceed the limit",
(count) => {
let collect: (
_event: unknown,
paths: string[],
) => { success: boolean; files: unknown[]; truncated: boolean };
vm.runInNewContext(collector + handler, {
fs: {
lstatSync: (p: string) => ({
isSymbolicLink: () => false,
isFile: () => p !== "/root",
isDirectory: () => p === "/root",
size: 1,
birthtime: new Date(0),
mtime: new Date(0),
}),
readdirSync: () =>
Array.from({ length: count }, (_, i) => `file${i}`),
},
path: path.posix,
normalizeRelativeLocalPath: (p: string) => p,
ipcMain: {
handle: (_name: string, callback: typeof collect) => {
collect = callback;
},
},
});
const result = collect!(null, ["/root"]);
expect(result.success).toBe(true);
expect(result.files).toHaveLength(Math.min(10000, count));
expect(result.truncated).toBe(count > 10000);
},
);
});
File diff suppressed because it is too large. Load diff
@@ -1,36 +0,0 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { buildRdpFile, validateNativeRdpOptions } =
require("../../../../electron/native-rdp.cjs") as {
buildRdpFile: (options: Record<string, unknown>) => string;
validateNativeRdpOptions: (options: Record<string, unknown>) => unknown;
};
describe("native Windows RDP", () => {
it("creates a credential-prompting RDP file without a password", () => {
const result = buildRdpFile({
host: "server.example.com",
port: 3390,
username: "alice",
domain: "EXAMPLE",
password: "must-not-be-written",
});
expect(result).toContain("full address:s:server.example.com:3390");
expect(result).toContain("username:s:EXAMPLE\\alice");
expect(result).toContain("prompt for credentials:i:1");
expect(result).not.toContain("must-not-be-written");
});
it("rejects unsafe host and port values", () => {
expect(() =>
validateNativeRdpOptions({ host: "bad\r\nvalue" }),
).not.toThrow();
expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow();
expect(() =>
validateNativeRdpOptions({ host: "server", port: 70000 }),
).toThrow();
});
});
@@ -1,41 +0,0 @@
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const { renameLocalPath } = createRequire(import.meta.url)(
"../../../../electron/rename-local-path.cjs",
);
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-rename-"));
});
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
describe("local rename", () => {
it("preserves both files on a name collision", () => {
const a = path.join(root, "a"),
b = path.join(root, "b");
fs.writeFileSync(a, "source");
fs.writeFileSync(b, "destination");
expect(() => renameLocalPath(a, b)).toThrow(/already exists/);
expect(fs.readFileSync(a, "utf8")).toBe("source");
expect(fs.readFileSync(b, "utf8")).toBe("destination");
});
it("refuses to replace even an empty destination directory", () => {
const a = path.join(root, "a"),
b = path.join(root, "b");
fs.mkdirSync(a);
fs.mkdirSync(b);
expect(() => renameLocalPath(a, b)).toThrow(/already exists/);
expect(fs.readdirSync(root).sort()).toEqual(["a", "b"]);
});
it("renames a file and handles an unchanged name", () => {
const a = path.join(root, "a"),
b = path.join(root, "b");
fs.writeFileSync(a, "source");
renameLocalPath(a, a);
renameLocalPath(a, b);
expect(fs.existsSync(a)).toBe(false);
expect(fs.readFileSync(b, "utf8")).toBe("source");
});
});
@@ -0,0 +1,103 @@
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
performPortKnocking: vi.fn(),
createJumpHostChain: vi.fn(),
createSocks5Connection: vi.fn(),
}));
vi.mock("../../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return { logger: log, sshLogger: log };
});
vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({
performPortKnocking: mocks.performPortKnocking,
}));
vi.mock("../../../hosts/jump-host-chain.js", () => ({
createJumpHostChain: mocks.createJumpHostChain,
}));
vi.mock("../../../utils/socks5-helper.js", () => ({
createSocks5Connection: mocks.createSocks5Connection,
}));
vi.mock("../../../hosts/ssh-dns.js", () => ({
resolveSshConnectConfigHost: async (config: unknown) => config,
}));
const { clearSshTransports, openProvidedTransport, provideSshTransport } =
await import("../../../hosts/connect/transport-providers.js");
const { openSshTransport } =
await import("../../../hosts/connect/transport.js");
const host = { id: 7, ip: "10.0.0.7", port: 22, username: "root" };
afterEach(() => {
clearSshTransports();
vi.clearAllMocks();
});
describe("ssh transport providers", () => {
it("opens nothing when no provider claims the host", async () => {
provideSshTransport("a", { claims: () => false, open: vi.fn() });
expect(await openProvidedTransport(host)).toBeNull();
});
it("hands back the claiming provider's stream", async () => {
const stream = new PassThrough();
const open = vi.fn(async () => stream);
provideSshTransport("a", { claims: (id) => id === 7, open });
expect(await openProvidedTransport(host)).toBe(stream);
expect(open).toHaveBeenCalledWith(host);
expect(await openProvidedTransport({ ...host, id: 8 })).toBeNull();
});
it("treats a provider that throws while claiming as not claiming", async () => {
provideSshTransport("a", {
claims: () => {
throw new Error("boom");
},
open: vi.fn(),
});
expect(await openProvidedTransport(host)).toBeNull();
});
it("stops using a provider once it is removed", async () => {
const remove = provideSshTransport("a", {
claims: () => true,
open: async () => new PassThrough(),
});
remove();
expect(await openProvidedTransport(host)).toBeNull();
});
});
describe("openSshTransport with a provider", () => {
it("uses the provided stream and skips knocking, jump hosts and proxies", async () => {
const stream = new PassThrough();
provideSshTransport("a", { claims: () => true, open: async () => stream });
const config: Record<string, unknown> = {};
const result = await openSshTransport(
{
...host,
portKnockSequence: [{ port: 1 }],
jumpHosts: [{ hostId: 1 }],
useSocks5: true,
socks5Host: "proxy",
userId: "u1",
} as never,
config as never,
);
expect(result).toEqual({ jumpClient: null, via: "provided" });
expect(config.sock).toBe(stream);
expect(mocks.performPortKnocking).not.toHaveBeenCalled();
expect(mocks.createJumpHostChain).not.toHaveBeenCalled();
expect(mocks.createSocks5Connection).not.toHaveBeenCalled();
});
it("connects directly when nothing claims the host", async () => {
const config: Record<string, unknown> = {};
const result = await openSshTransport(host as never, config as never);
expect(result.via).toBe("direct");
expect(config.sock).toBeUndefined();
});
});
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
hosts: [] as Record<string, unknown>[],
credentials: [] as Record<string, unknown>[],
resolve: vi.fn(),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findHostsByUserId: async () => state.hosts,
findHostByIdForUser: async (id: number) =>
state.hosts.find((host) => host.id === id) ?? null,
}),
createCurrentCredentialRepository: () => ({
listDecryptedByUserId: async () => state.credentials,
}),
}));
vi.mock("../../../database/routes/host-normalizers.js", () => ({
hostTerminalExport: () => ({ terminalConfig: null, sshOptions: null }),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
loadHostPluginSettings: async () => new Map(),
}));
vi.mock("../../../hosts/protocol-auth/protocol-auth.js", () => ({
listProtocolLogins: async () => [],
toPortableLogins: () => ({
rdp: {
authType: "credential",
credentialId: 4,
username: "bob",
password: "rdp-secret",
fields: { domain: "corp", token: "t" },
},
}),
}));
vi.mock("../../../hosts/protocol-auth/registry.js", () => ({
findHostProtocol: () => ({
credentialFields: [{ key: "token", secret: true }, { key: "domain" }],
}),
}));
vi.mock("../../../hosts/resolve-host-credentials.js", () => ({
resolveHostCredentials: state.resolve,
}));
import { exportAllHosts } from "../../../hosts/import/host-export.js";
const sshHost = {
id: 1,
connectionType: "ssh",
name: "web",
ip: "192.0.2.1",
port: 22,
username: "alice",
password: "pw",
key: "KEY",
keyPassword: "kp",
sudoPassword: "sp",
socks5Password: "s5",
authType: "credential",
credentialId: 7,
folder: "Prod",
tags: "a,b",
jumpHosts: JSON.stringify([{ hostId: 2 }]),
forceKeyboardInteractive: "true",
};
beforeEach(() => {
state.hosts = [{ ...sshHost }];
state.credentials = [
{ id: 7, name: "deploy", authType: "key", username: "alice", tags: "x" },
];
state.resolve.mockReset().mockImplementation(async (host) => ({
...host,
password: "resolved-pw",
key: "RESOLVED-KEY",
}));
});
describe("exportAllHosts", () => {
it("includes decrypted secrets without share mode", async () => {
const result = await exportAllHosts("u1", false);
expect(result.credentials).toBeUndefined();
expect(result.hosts[0]).toMatchObject({
exportId: 1,
password: "resolved-pw",
key: "RESOLVED-KEY",
sudoPassword: "sp",
credentialId: 7,
tags: ["a", "b"],
jumpHosts: [{ hostId: 2 }],
forceKeyboardInteractive: true,
});
expect(state.resolve).toHaveBeenCalled();
});
it("leaves out every secret and credential id in share mode", async () => {
const result = await exportAllHosts("u1", true);
const [host] = result.hosts;
expect(state.resolve).not.toHaveBeenCalled();
expect(host).toMatchObject({
password: null,
key: null,
keyPassword: null,
sudoPassword: null,
socks5Password: null,
credentialAlias: "deploy",
});
expect(host).not.toHaveProperty("credentialId");
expect(result.credentials).toEqual([
expect.objectContaining({ alias: "deploy", authType: "key" }),
]);
expect(JSON.stringify(result)).not.toMatch(/rdp-secret|"t"/);
expect(host.protocolAuth).toMatchObject({
rdp: {
authType: "direct",
credentialId: null,
password: null,
fields: { domain: "corp" },
},
});
expect(result.version).toBe("1");
});
it("exports a remote desktop host without SSH fields", async () => {
state.hosts = [{ ...sshHost, connectionType: "rdp" }];
const result = await exportAllHosts("u1", false);
expect(result.hosts[0]).not.toHaveProperty("authType");
expect(result.hosts[0]).toHaveProperty("protocolAuth");
});
});
describe("exportAllHosts with hostIds", () => {
it("exports only the asked hosts the user owns", async () => {
state.hosts = [
{ ...sshHost, id: 1 },
{ ...sshHost, id: 2 },
];
const result = await exportAllHosts("u1", false, [2, 99]);
expect(result.hosts.map((host) => host.exportId)).toEqual([2]);
});
it("returns nothing for an id the user does not own", async () => {
const result = await exportAllHosts("u1", false, [99]);
expect(result.hosts).toEqual([]);
});
});
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
list: vi.fn(),
applyDefaults: vi.fn(async () => {}),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: vi.fn(),
setHostPluginEnabled: vi.fn(),
}));
vi.mock("../../../hosts/defaults/index.js", () => ({
applyHostDefaultsToWrite: async () => ({ core: [] }),
applyDefaultsAfterHostWrite: async () => {},
applyDefaultsAfterHostWrites: mocks.applyDefaults,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostDefaultsRepository: () => ({ listHosts: async () => [] }),
createCurrentCredentialRepository: () => ({
listDecryptedByUserId: async () => [],
}),
createCurrentHostRepository: () => ({
createEncryptedForUser: mocks.create,
updateEncryptedForUser: mocks.update,
deleteForUser: mocks.remove,
}),
createCurrentHostResolutionRepository: () => ({
findHostsByUserId: mocks.list,
}),
}));
import {
ImportError,
runBulkImport,
runSshConfigImport,
} from "../../../hosts/import/host-import.js";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
const good = {
ip: "192.0.2.1",
port: 22,
username: "alice",
authType: "none",
};
beforeEach(() => {
vi.clearAllMocks();
mocks.list.mockResolvedValue([]);
mocks.remove.mockResolvedValue({ syncId: null });
});
describe("runBulkImport", () => {
it("creates hosts, returns their ids and tells listeners", async () => {
mocks.create.mockResolvedValueOnce({ id: 11 });
const created: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (p) => created.push(p));
try {
const result = await runBulkImport("u1", { hosts: [good] });
expect(result).toMatchObject({ success: 1, failed: 0 });
expect(result.createdHostIds).toEqual([11]);
expect(mocks.applyDefaults).toHaveBeenCalledWith([11]);
} finally {
off();
}
expect(created).toEqual([{ hostId: 11, userId: "u1" }]);
});
it("refuses an empty list and more than 100 hosts", async () => {
await expect(runBulkImport("u1", { hosts: [] })).rejects.toBeInstanceOf(
ImportError,
);
await expect(
runBulkImport("u1", {
hosts: Array.from({ length: 101 }, () => ({ ...good })),
}),
).rejects.toThrow(/Maximum 100/);
});
it("keeps the rows that worked when atomic is off", async () => {
mocks.create.mockResolvedValueOnce({ id: 11 });
const result = await runBulkImport("u1", {
hosts: [good, { ...good, ip: "" }],
});
expect(result).toMatchObject({ success: 1, failed: 1 });
expect(mocks.remove).not.toHaveBeenCalled();
});
it("deletes what it created and stays quiet when atomic and a row fails", async () => {
mocks.create
.mockResolvedValueOnce({ id: 11 })
.mockResolvedValueOnce({ id: 12 });
const events: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (p) => events.push(p));
try {
const result = await runBulkImport("u1", {
hosts: [good, { ...good, ip: "192.0.2.2" }, { ...good, ip: "" }],
atomic: true,
});
expect(result.rolledBack).toBe(true);
expect(result.success).toBe(0);
expect(result.createdHostIds).toEqual([]);
expect(result.failed).toBe(1);
} finally {
off();
}
expect(mocks.remove.mock.calls.map((call) => call[1])).toEqual([12, 11]);
expect(mocks.applyDefaults).not.toHaveBeenCalled();
expect(events).toEqual([]);
});
it("refuses overwrite together with atomic", async () => {
await expect(
runBulkImport("u1", { hosts: [good], overwrite: true, atomic: true }),
).rejects.toThrow(/cannot overwrite/);
});
});
describe("runSshConfigImport", () => {
const config = "Host web\n HostName 192.0.2.9\n User bob\n";
it("imports Host blocks", async () => {
mocks.create.mockResolvedValueOnce({ id: 31 });
const result = await runSshConfigImport("u1", { content: config });
expect(result).toMatchObject({ success: 1, createdHostIds: [31] });
});
it("rejects empty content", async () => {
await expect(
runSshConfigImport("u1", { content: " " }),
).rejects.toBeInstanceOf(ImportError);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: { warn: vi.fn() },
}));
const {
assertHostsUnlocked,
assertManualCreateAllowed,
clearHostLocks,
hasHostLocks,
hostLock,
HostLockedError,
manualCreateBlockedBy,
provideHostLocks,
requireManualCreate,
requireUnlockedHost,
} = await import("../../hosts/locks.js");
const provider = (locked: number[], allowManualCreate = true) => ({
isLocked: (hostId: number) => locked.includes(hostId),
note: (hostId: number) => `id ${hostId}`,
allowManualCreate: () => allowManualCreate,
});
function fakeRes() {
const res = {
code: 0,
body: null as unknown,
status(code: number) {
res.code = code;
return { json: (body: unknown) => (res.body = body) };
},
};
return res;
}
afterEach(() => clearHostLocks());
describe("host locks", () => {
it("reports nothing without providers", () => {
expect(hasHostLocks()).toBe(false);
expect(hostLock(1)).toBeNull();
expect(() => assertHostsUnlocked([1, 2])).not.toThrow();
expect(manualCreateBlockedBy()).toBeNull();
});
it("names the plugin and note for a locked host", () => {
provideHostLocks("cfg", "Config", provider([3]));
expect(hostLock(3)).toEqual({
pluginId: "cfg",
pluginName: "Config",
note: "id 3",
});
expect(hostLock(4)).toBeNull();
expect(() => assertHostsUnlocked([4, 3])).toThrow(HostLockedError);
});
it("lets the locking plugin through", () => {
provideHostLocks("cfg", "Config", provider([3], false));
expect(() => assertHostsUnlocked([3], "cfg")).not.toThrow();
expect(() => assertManualCreateAllowed("cfg")).not.toThrow();
expect(() => assertManualCreateAllowed()).toThrow(/managed by Config/);
});
it("drops a provider when removed", () => {
const remove = provideHostLocks("cfg", "Config", provider([3], false));
remove();
expect(hostLock(3)).toBeNull();
expect(manualCreateBlockedBy()).toBeNull();
});
it("never locks when a provider throws", () => {
provideHostLocks("cfg", "Config", {
isLocked: () => {
throw new Error("broken");
},
note: () => null,
allowManualCreate: () => {
throw new Error("broken");
},
});
expect(hostLock(1)).toBeNull();
expect(manualCreateBlockedBy()).toBeNull();
});
});
describe("lock route guards", () => {
it("answers 403 HOST_LOCKED for a locked :id", () => {
provideHostLocks("cfg", "Config", provider([7]));
const res = fakeRes();
const next = vi.fn();
requireUnlockedHost({ params: { id: "7" } }, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.code).toBe(403);
expect(res.body).toMatchObject({ code: "HOST_LOCKED", lockedBy: "cfg" });
requireUnlockedHost({ params: { id: "8" } }, fakeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
it("blocks manual creates only while a provider says so", () => {
const next = vi.fn();
requireManualCreate({}, fakeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
provideHostLocks("cfg", "Config", provider([], false));
const res = fakeRes();
requireManualCreate({}, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res.code).toBe(403);
});
});
+138
View File
@@ -0,0 +1,138 @@
import net from "node:net";
import type { AddressInfo } from "node:net";
import http from "node:http";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
const mocks = vi.hoisted(() => ({
resolveHostById: vi.fn(),
openSshTransport: vi.fn(),
handler: null as null | ((connection: unknown) => Promise<void>),
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return { sshLogger: log, logger: log };
});
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: mocks.resolveHostById,
}));
vi.mock("../../hosts/connect/transport.js", () => ({
openSshTransport: mocks.openSshTransport,
}));
vi.mock("../../plugins/ws.js", () => ({
registerCoreWsRoute: (_path: string, handler: never) => {
mocks.handler = handler;
return () => {};
},
}));
const { registerSshRelay } = await import("../../hosts/ssh-relay.js");
let echo: net.Server;
let echoPort = 0;
let relayServer: http.Server;
let wss: WebSocketServer;
let relayUrl = "";
beforeEach(async () => {
vi.clearAllMocks();
registerSshRelay();
echo = net.createServer((socket) => socket.pipe(socket));
await new Promise<void>((resolve) => echo.listen(0, "127.0.0.1", resolve));
echoPort = (echo.address() as AddressInfo).port;
relayServer = http.createServer();
wss = new WebSocketServer({ server: relayServer });
wss.on("connection", (socket, request) => {
void mocks.handler?.({ userId: "user-1", request, socket });
});
await new Promise<void>((resolve) =>
relayServer.listen(0, "127.0.0.1", resolve),
);
relayUrl = `ws://127.0.0.1:${(relayServer.address() as AddressInfo).port}`;
mocks.resolveHostById.mockResolvedValue({
id: 5,
ip: "127.0.0.1",
port: echoPort,
});
mocks.openSshTransport.mockResolvedValue({ jumpClient: null, via: "direct" });
});
afterEach(async () => {
wss.close();
await new Promise((resolve) => relayServer.close(resolve));
await new Promise((resolve) => echo.close(resolve));
});
function open(query: string): Promise<{
socket: WebSocket;
closed: Promise<{ code: number; reason: string }>;
}> {
return new Promise((resolve) => {
const socket = new WebSocket(`${relayUrl}/${query}`);
const closed = new Promise<{ code: number; reason: string }>((done) =>
socket.once("close", (code, reason) =>
done({ code, reason: reason.toString() }),
),
);
socket.once("open", () => resolve({ socket, closed }));
});
}
describe("ssh relay", () => {
it("carries bytes to the host and back", async () => {
const { socket } = await open("?hostId=5");
const received = new Promise<string>((resolve) =>
socket.once("message", (data) => resolve(data.toString())),
);
socket.send(Buffer.from("SSH-2.0-test\r\n"));
expect(await received).toBe("SSH-2.0-test\r\n");
expect(mocks.resolveHostById).toHaveBeenCalledWith(5, "user-1");
socket.close();
});
it("uses the stream the transport made, such as a jump host channel", async () => {
const channel = new PassThrough();
const end = vi.fn();
mocks.openSshTransport.mockImplementation(
async (_host: unknown, config: { sock?: unknown }) => {
config.sock = channel;
return { jumpClient: { end }, via: "jump" };
},
);
const { socket } = await open("?hostId=5");
const received = new Promise<string>((resolve) =>
socket.once("message", (data) => resolve(data.toString())),
);
socket.send("ping");
expect(await received).toBe("ping");
socket.close();
await vi.waitFor(() => expect(end).toHaveBeenCalled());
});
it("refuses a host the user can't connect to", async () => {
mocks.resolveHostById.mockResolvedValue(null);
const { closed } = await open("?hostId=5");
expect(await closed).toMatchObject({
code: 1008,
reason: "Host not found or access denied",
});
expect(mocks.openSshTransport).not.toHaveBeenCalled();
});
it("needs a host id", async () => {
const { closed } = await open("");
expect((await closed).code).toBe(1008);
expect(mocks.resolveHostById).not.toHaveBeenCalled();
});
it("closes the socket when the host can't be reached", async () => {
mocks.openSshTransport.mockRejectedValue(new Error("no route"));
const { closed } = await open("?hostId=5");
expect((await closed).code).toBe(1000);
});
});
@@ -0,0 +1,111 @@
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { generateKeyPairSync } from "node:crypto";
import { expect, it } from "vitest";
const require = createRequire(import.meta.url);
const ssh2 = require("ssh2");
const {
patchKeyParserSource,
patchAgentSource,
patchProtocolSource,
} = require("../../../../scripts/patch-ssh2-sk.cjs");
const SK_ED = "sk-ssh-ed25519@openssh.com";
function str(value: Buffer | string): Buffer {
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
const len = Buffer.alloc(4);
len.writeUInt32BE(buf.length);
return Buffer.concat([len, buf]);
}
function skPublicBlob(): Buffer {
const { publicKey } = generateKeyPairSync("ed25519");
const raw = publicKey.export({ format: "der", type: "spki" }).subarray(-32);
return Buffer.concat([str(SK_ED), str(raw), str("ssh:")]);
}
it("patches are idempotent on the installed dependency", () => {
const files: Array<[string, (s: string) => string]> = [
["ssh2/lib/protocol/keyParser.js", patchKeyParserSource],
["ssh2/lib/agent.js", patchAgentSource],
["ssh2/lib/protocol/Protocol.js", patchProtocolSource],
];
for (const [id, patch] of files) {
const source = readFileSync(require.resolve(id), "utf8");
const result = patch(source);
expect(patch(result)).toBe(result);
}
});
it("fails explicitly when the dependency changes", () => {
expect(() => patchKeyParserSource("changed")).toThrow("review");
expect(() => patchAgentSource("changed")).toThrow("review");
expect(() => patchProtocolSource("changed")).toThrow("review");
});
it("parses a security key public blob and keeps it byte for byte", () => {
const blob = skPublicBlob();
const key = ssh2.utils.parseKey(blob);
expect(key).not.toBeInstanceOf(Error);
expect(key.type).toBe(SK_ED);
expect(Buffer.compare(key.getPublicSSH(), blob)).toBe(0);
});
it("sends flags and counter after the signature in publickey auth", async () => {
const blob = skPublicBlob();
const key = ssh2.utils.parseKey(blob);
const trailer = Buffer.from([0x01, 0, 0, 0, 7]);
const signature = Buffer.alloc(64, 9);
(signature as Buffer & { skTrailer?: Buffer }).skTrailer = trailer;
class FakeAgent extends ssh2.BaseAgent {
getIdentities(cb: (err: Error | null, keys?: unknown[]) => void) {
cb(null, [key]);
}
sign(
_key: unknown,
_data: Buffer,
_opts: unknown,
cb: (err: Error | null, sig?: Buffer) => void,
) {
cb(null, signature);
}
}
const hostKey = ssh2.utils.generateKeyPairSync("ed25519").private;
const received = await new Promise<Buffer>((resolve) => {
const server = new ssh2.Server({ hostKeys: [hostKey] }, (client: any) => {
client.on("authentication", (ctx: any) => {
if (ctx.method !== "publickey") return ctx.reject(["publickey"]);
if (!ctx.signature) return ctx.accept();
resolve(
Buffer.concat([
Buffer.from(ctx.key.data),
Buffer.from(ctx.signature),
]),
);
ctx.reject();
});
client.on("error", () => {});
});
server.listen(0, "127.0.0.1", () => {
const conn = new ssh2.Client();
conn.on("error", () => {});
conn.on("close", () => server.close());
conn.connect({
host: "127.0.0.1",
port: server.address().port,
username: "u",
agent: new FakeAgent(),
readyTimeout: 5000,
});
});
});
expect(
Buffer.compare(
received,
Buffer.concat([blob, Buffer.alloc(64, 9), trailer]),
),
).toBe(0);
});
@@ -1,312 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: vi.fn(),
getCurrentSettingValue: vi.fn(() => null),
}));
vi.mock("../../../utils/logger.js", () => ({
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
pluginLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const { HostStatusService, toStatusTarget } =
await import("../../../hosts/status/host-status-service.js");
const { hostSessionStatus } =
await import("../../../hosts/host-session-status.js");
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
type Target = ReturnType<typeof toStatusTarget>;
function target(id: number, extra: Partial<Target> = {}): Target {
return {
id,
userId: "owner",
ip: `10.0.0.${id}`,
port: 22,
connectionType: "ssh",
jumpHosts: [],
statusCheckEnabled: true,
statusCheckInterval: null,
...extra,
};
}
function setup(
targets: Target[],
reachable = true,
shared: Record<string, number[]> = {},
) {
const emitted: unknown[] = [];
const ping = vi.fn(async () => reachable);
const pingThroughJumpHosts = vi.fn(async () => reachable);
const loadTargets = vi.fn(
async (filter: { userId?: string; hostIds?: number[] }) =>
targets.filter(
(t) =>
(!filter.userId || t.userId === filter.userId) &&
(!filter.hostIds || filter.hostIds.includes(t.id)),
),
);
const service = new HostStatusService({
loadTargets,
loadSharedHostIds: async (userId) => shared[userId] ?? [],
ping,
pingThroughJumpHosts,
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
globalInterval: () => 60,
emit: (payload) => emitted.push(payload),
retryDelayMs: 0,
});
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
let active: InstanceType<typeof HostStatusService> | null = null;
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
});
afterEach(() => {
active?.stop();
active = null;
pluginEvents.clear();
vi.useRealTimers();
});
describe("HostStatusService", () => {
it.each([{ jumpHosts: [] }, { jumpHosts: [{ hostId: 9 }] }])(
"skips probes while a session is open, including jump hosts: %j",
async ({ jumpHosts }) => {
const { service, ping, pingThroughJumpHosts } = setup([
target(7, { jumpHosts }),
]);
active = service;
const close = hostSessionStatus.register(7);
try {
await service.statusesFor("owner", null);
await flush();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(service.get(7)?.status).toBe("online");
expect(ping).not.toHaveBeenCalled();
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
close();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(
jumpHosts.length ? pingThroughJumpHosts : ping,
).toHaveBeenCalledOnce();
} finally {
close();
}
},
);
it("starts a user's own hosts and reports them online", async () => {
const { service, emitted, ping } = setup([target(1), target(2)]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.1", 22);
expect(service.get(1)?.status).toBe("online");
expect(emitted).toContainEqual({
hostId: 1,
ownerUserId: "owner",
status: "online",
previous: null,
online: true,
});
});
it("checks a host shared with the user before its owner ever asks", async () => {
const { service, ping } = setup([target(5)], true, { recipient: [5] });
active = service;
await service.statusesFor("recipient", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.5", 22);
expect(service.get(5)?.status).toBe("online");
});
it("only starts the shared hosts a desktop request names", async () => {
const { service, ping } = setup([target(5), target(6)], true, {
recipient: [5, 6],
});
active = service;
await service.statusesFor("recipient", new Set([6]));
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.6", 22);
expect(ping).not.toHaveBeenCalledWith("10.0.0.5", 22);
});
it("reports offline for a host that does not answer", async () => {
const { service } = setup([target(1)], false);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(service.get(1)?.status).toBe("offline");
});
it("never checks a host with status checks off", async () => {
const { service, ping } = setup([target(1, { statusCheckEnabled: false })]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).not.toHaveBeenCalled();
expect(service.get(1)).toBeNull();
});
it("goes through the jump hosts when a host has them", async () => {
const { service, ping, pingThroughJumpHosts } = setup([
target(1, { jumpHosts: [{ hostId: 9 }] }),
]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).not.toHaveBeenCalled();
expect(pingThroughJumpHosts).toHaveBeenCalledOnce();
});
it("asks a registered plugin for a protocol's port", async () => {
const { service, ping } = setup([target(1, { connectionType: "rdp" })]);
active = service;
service.registerPort("rdp", async () => 3390);
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3390);
});
it("falls back to the host's port when no plugin knows the protocol", async () => {
const { service, ping } = setup([
target(1, { connectionType: "rdp", port: 3389 }),
]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389);
});
it("ignores a failed login and counts a working one as online", async () => {
const { service, emitted } = setup([target(1)]);
active = service;
await service.statusesFor("owner", null);
const before = emitted.length;
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("online");
expect(emitted).toHaveLength(before);
});
it("marks a host online when a terminal session opens", async () => {
const { service } = setup([target(1)], false);
active = service;
service.start();
await service.statusesFor("owner", null);
expect(service.get(1)?.status).toBe("offline");
const release = hostSessionStatus.register(1);
expect(service.get(1)?.status).toBe("online");
release();
});
it("tries a failed ping again before reporting offline", async () => {
const { service, ping } = setup([target(1)]);
active = service;
ping.mockResolvedValueOnce(false);
await service.statusesFor("owner", null);
expect(ping).toHaveBeenCalledTimes(2);
expect(service.get(1)?.status).toBe("online");
});
it("answers the first request with the first checks done", async () => {
const { service } = setup([target(1), target(2)]);
active = service;
const statuses = await service.statusesFor("owner", null);
expect(statuses.get(1)?.status).toBe("online");
expect(statuses.get(2)?.status).toBe("online");
});
it("only checks the requested hosts on the desktop app", async () => {
const { service, ping } = setup([target(1), target(2)]);
active = service;
await service.statusesFor("owner", new Set([2]));
await flush();
expect(ping).toHaveBeenCalledTimes(1);
expect(ping).toHaveBeenCalledWith("10.0.0.2", 22);
});
it("checks again on its interval", async () => {
const { service, ping } = setup([target(1, { statusCheckInterval: 10 })]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(15_000);
await flush();
expect(ping).toHaveBeenCalledTimes(2);
});
it("checks a host on demand unless the last result is fresh", async () => {
const { service, ping } = setup([target(1)]);
active = service;
expect(await service.check(1)).toMatchObject({ status: "online" });
expect(await service.check(1)).toMatchObject({ status: "online" });
expect(ping).toHaveBeenCalledTimes(1);
});
it("forgets a deleted host", async () => {
const { service } = setup([target(1)]);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
pluginEvents.emit(TOPICS.hostDeleted, { hostId: 1 });
expect(service.get(1)).toBeNull();
});
it("never logs in to check a host", async () => {
// The only transports are the two ping functions: nothing here can
// authenticate, so a RADIUS or Duo backed host gets no 2FA push.
const { service, ping, pingThroughJumpHosts } = setup([target(1)]);
active = service;
await service.statusesFor("owner", null);
await flush();
expect(ping.mock.calls[0]).toEqual(["10.0.0.1", 22]);
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
});
});
describe("toStatusTarget", () => {
it("parses the jump hosts and defaults the connection type", () => {
expect(
toStatusTarget({
id: 1,
userId: "u",
ip: "h",
port: 22,
connectionType: "",
jumpHosts: '[{"hostId":3},{"bad":1}]',
statusCheckEnabled: false,
statusCheckInterval: 30,
}),
).toEqual({
id: 1,
userId: "u",
ip: "h",
port: 22,
connectionType: "ssh",
jumpHosts: [{ hostId: 3 }],
statusCheckEnabled: false,
statusCheckInterval: 30,
});
});
});
@@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest";
import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js";
describe("isHostKeyVerificationError", () => {
it.each([
"Host denied (verification failed)",
"Host key changed - please connect via Terminal to verify the new key",
])("classifies %s", (message) => {
expect(isHostKeyVerificationError(new Error(message))).toBe(true);
});
it("does not classify ordinary authentication failures", () => {
expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe(
false,
);
});
});
@@ -1,162 +0,0 @@
import { describe, expect, it } from "vitest";
import { ConcurrentLimiter } from "../../../hosts/status/limiter.js";
describe("ConcurrentLimiter", () => {
it("never exceeds max concurrent runners", async () => {
const limiter = new ConcurrentLimiter(2);
let peak = 0;
let current = 0;
const job = async () => {
current += 1;
peak = Math.max(peak, current);
await new Promise((r) => setTimeout(r, 30));
current -= 1;
};
await Promise.all([
limiter.run(job),
limiter.run(job),
limiter.run(job),
limiter.run(job),
]);
expect(peak).toBeLessThanOrEqual(2);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
});
it("runs waiters in FIFO order after a slot frees", async () => {
const limiter = new ConcurrentLimiter(1);
const order: number[] = [];
const first = limiter.run(async () => {
order.push(1);
await new Promise((r) => setTimeout(r, 20));
});
const second = limiter.run(async () => {
order.push(2);
});
const third = limiter.run(async () => {
order.push(3);
});
await Promise.all([first, second, third]);
expect(order).toEqual([1, 2, 3]);
});
it("rejects invalid maxConcurrent", () => {
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
});
describe("setLimit", () => {
it("releases queued waiters as soon as the ceiling is raised", async () => {
const limiter = new ConcurrentLimiter(1);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(1);
expect(limiter.pendingCount).toBe(3);
// Widening must drain the backlog without waiting for the running job.
limiter.setLimit(4);
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(4);
expect(limiter.pendingCount).toBe(0);
release.forEach((fn) => fn());
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
});
it("does not over-release beyond the new ceiling", async () => {
const limiter = new ConcurrentLimiter(1);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
// Later waves of woken jobs enqueue their own resolvers, so draining has
// to keep going until nothing is left rather than flushing a snapshot.
const drain = async () => {
while (release.length > 0) {
release.splice(0).forEach((fn) => fn());
await new Promise((r) => setTimeout(r, 5));
}
};
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
limiter.setLimit(3);
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(3);
expect(limiter.pendingCount).toBe(2);
await drain();
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
});
it("lets running work finish when the ceiling shrinks", async () => {
const limiter = new ConcurrentLimiter(4);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
const drain = async () => {
while (release.length > 0) {
release.splice(0).forEach((fn) => fn());
await new Promise((r) => setTimeout(r, 5));
}
};
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(4);
// Shrinking never kills in-flight work; it applies to later releases.
limiter.setLimit(2);
expect(limiter.activeCount).toBe(4);
await drain();
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
// The two queued jobs ran only after the shrink, so they never pushed
// occupancy back up to the old width.
expect(peak).toBe(4);
});
it("rejects an invalid new limit", () => {
const limiter = new ConcurrentLimiter(2);
expect(() => limiter.setLimit(0)).toThrow(/maxConcurrent/);
expect(limiter.limit).toBe(2);
});
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
const access = vi.hoisted(() => ({ owned: true, shared: [] as number[] }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
isHostOwnedByUser: async () => access.owned,
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentRbacAccessRepository: () => ({
listVisibleHostAccessEntries: async () =>
access.shared.map((hostId) => ({ hostId })),
}),
}));
import {
provideActivity,
recentActivity,
type ActivityProvider,
} from "../../plugins/activity-provider.js";
function fake(): ActivityProvider {
return {
record: vi.fn(async () => ({ status: "logged" as const, id: 1 })),
list: vi.fn(async () => []),
deleteByUserId: vi.fn(async () => {}),
deleteByHostIds: vi.fn(async () => {}),
};
}
const entry = { type: "ssh", hostId: 1, hostName: "h" };
describe("recent activity provider", () => {
it("is unavailable with no provider", async () => {
expect(await recentActivity.record("u", entry)).toEqual({
status: "unavailable",
});
expect(await recentActivity.list("u", 5)).toEqual([]);
await expect(recentActivity.deleteByUserId("u")).resolves.toBeUndefined();
await expect(recentActivity.deleteByHostId(7)).resolves.toBeUndefined();
});
it("routes to the plugin and back to nothing when it goes", async () => {
const plugin = fake();
const remove = provideActivity("homepage", plugin);
await recentActivity.record("u", entry);
await recentActivity.deleteByHostId(7);
expect(plugin.record).toHaveBeenCalledOnce();
expect(plugin.deleteByHostIds).toHaveBeenCalledWith([7]);
remove();
expect(await recentActivity.list("u", 5)).toEqual([]);
expect(plugin.list).not.toHaveBeenCalled();
});
it("checks host access before the provider records", async () => {
const plugin = fake();
const remove = provideActivity("homepage", plugin);
access.owned = false;
access.shared = [];
expect(await recentActivity.record("u", entry)).toEqual({
status: "denied",
});
expect(plugin.record).not.toHaveBeenCalled();
access.shared = [1];
expect((await recentActivity.record("u", entry)).status).toBe("logged");
remove();
access.owned = true;
});
it("refuses a second provider", () => {
const remove = provideActivity("one", fake());
expect(() => provideActivity("two", fake())).toThrow(
/already provided by plugin "one"/,
);
remove();
});
});
+46
View File
@@ -108,6 +108,7 @@ describe("describePluginFrontend", () => {
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
});
});
});
@@ -175,3 +176,48 @@ describe("GET /plugin-assets", () => {
}
});
});
describe("theme stylesheets", () => {
let themed: { id: string; dir: string; manifest: never };
beforeAll(() => {
const dir = path.join(root, "inks");
fs.mkdirSync(path.join(dir, "themes"), { recursive: true });
fs.writeFileSync(path.join(dir, "themes", "paper.css"), ".paper{}");
fs.writeFileSync(path.join(dir, "themes", "secret.css"), ".secret{}");
themed = {
id: "inks",
dir,
manifest: {
id: "inks",
version: "1.0.0",
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "themes/paper.css",
},
],
},
} as never,
};
});
it("serves a declared theme's CSS under _themes/<id>.css only", () => {
expect(resolvePluginAsset(themed, "_themes/paper.css")).toBe(
path.join(themed.dir, "themes", "paper.css"),
);
expect(resolvePluginAsset(themed, "_themes/secret.css")).toBeNull();
expect(resolvePluginAsset(themed, "themes/paper.css")).toBeNull();
expect(resolvePluginAsset(themed, "_themes/paper.js")).toBeNull();
});
it("versions theme files even without a frontend bundle", () => {
const info = describePluginFrontend(themed);
expect(info.frontend).toBe(false);
expect(info.themeVersion).toMatch(/^[0-9a-f]{12}$/);
expect(describePluginFrontend(plugin).themeVersion).toBeNull();
});
});
@@ -3,7 +3,9 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
defaultLookPluginIds,
initialPluginState,
isDefaultLookPlugin,
onboardingDefaults,
resetBundledIndexCache,
} from "../../plugins/bundled-index.js";
@@ -103,3 +105,28 @@ describe("initialPluginState", () => {
expect(initialPluginState("kept", "user", false)).toBe("disabled");
});
});
describe("default look", () => {
it("lists the plugins flagged as the default look", () => {
writeIndex({
"some-layout": { defaultLook: true },
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
other: { onboarding: { recommended: true } },
});
expect(defaultLookPluginIds()).toEqual(["some-layout", "some-themes"]);
expect(isDefaultLookPlugin("some-themes")).toBe(true);
expect(isDefaultLookPlugin("other")).toBe(false);
});
it("has none without an index", () => {
expect(defaultLookPluginIds()).toEqual([]);
});
it("starts the default look enabled even on a fresh install", () => {
writeIndex({
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
});
expect(initialPluginState("some-themes", "bundled", true)).toBe("enabled");
expect(initialPluginState("some-themes", "user", true)).toBe("disabled");
});
});
+118 -71
View File
@@ -1,112 +1,159 @@
/**
* ctx.audit.record: a plugin writes an audit line under its own action name,
* and the user it names is always the runtime's actor, never one the plugin
* picked.
* ctx.audit.subscribe, list and exportBatches: audit:read gates reading, and
* subscribers see lines after they are accepted without delaying the writer.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const auditEntries: Array<Record<string, unknown>> = [];
const grants = new Map<string, string[]>();
const rows = vi.hoisted(() => ({ list: [] as Array<Record<string, unknown>> }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async () => [],
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
createCurrentAuditLogRepository: () => ({
listPage: async ({ limit }: { limit: number }) => ({
logs: rows.list.slice(0, limit),
total: rows.list.length,
}),
listForExport: async ({
limit,
offset,
}: {
limit: number;
offset: number;
}) => rows.list.slice(offset, offset + limit),
create: async () => {},
}),
createCurrentUserRepository: () => ({ findById: async () => null }),
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { runAsActor } from "../../plugins/actor.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { emitAuditEntry } from "../../utils/audit-hooks.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(pluginId: string) {
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = {
id: pluginId,
name: "Audit Fixture",
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return createPluginContext(manifest, handle);
return { ctx: createPluginContext(manifest, handle), handle };
}
const line = (id: number) => ({
id,
timestamp: new Date(1_700_000_000_000 + id),
userId: null,
username: "root",
action: "login",
resourceType: "user",
resourceId: null,
resourceName: null,
details: null,
ipAddress: null,
userAgent: null,
success: true,
errorMessage: null,
});
beforeEach(() => {
auditEntries.length = 0;
grants.clear();
invalidatePluginPermissionCache();
rows.list = [line(1), line(2), line(3)];
});
describe("ctx.audit.record", () => {
it("writes the plugin's action for the acting user", async () => {
const ctx = contextFor("audit-fixture");
await runAsActor("user-3", "request", () =>
ctx.audit.record({
action: "ssh_connect",
resourceType: "host",
resourceId: "12",
resourceName: "root@10.0.0.1:22",
success: true,
}),
describe("ctx.audit reading", () => {
it("refuses list and export without audit:read", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(ctx.audit.list()).rejects.toThrow(/audit:read/);
await expect(ctx.audit.exportBatches().next()).rejects.toThrow(
/audit:read/,
);
expect(auditEntries).toEqual([
expect.objectContaining({
userId: "user-3",
action: "ssh_connect",
resourceType: "host",
resourceId: "12",
resourceName: "root@10.0.0.1:22",
details: "via plugin audit-fixture",
success: true,
}),
]);
expect(() => ctx.audit.subscribe(() => {})).toThrow(/audit:read/);
});
it("falls back to the plugin as the resource and system as the user", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({ action: "cleanup", success: false });
expect(auditEntries[0]).toMatchObject({
it("lists stored lines", async () => {
grants.set("demo", ["audit:read"]);
const { ctx } = contextFor("demo", ["audit:read"]);
const page = await ctx.audit.list({ limit: 2 });
expect(page.total).toBe(3);
expect(page.entries.map((entry) => entry.id)).toEqual([1, 2]);
expect(page.entries[0].timestamp).toMatch(/^20\d\d-/);
});
it("exports in batches", async () => {
grants.set("demo", ["audit:read"]);
const { ctx } = contextFor("demo", ["audit:read"]);
const sizes: number[] = [];
for await (const batch of ctx.audit.exportBatches({}, 2)) {
sizes.push(batch.length);
}
expect(sizes).toEqual([2, 1]);
});
it("delivers new lines to a subscriber until it unsubscribes", async () => {
grants.set("demo", ["audit:read"]);
const { ctx } = contextFor("demo", ["audit:read"]);
const seen: string[] = [];
const stop = ctx.audit.subscribe((entry) => seen.push(entry.action));
await new Promise((resolve) => setTimeout(resolve, 20));
emitAuditEntry({
userId: null,
username: "system",
resourceType: "plugin",
resourceId: "audit-fixture",
resourceName: "Audit Fixture",
success: false,
});
});
});
describe("ctx.audit.record with a request", () => {
it("records the request's IP address and user agent", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({
action: "collab_room_end",
username: "root",
action: "one",
resourceType: "x",
success: true,
request: {
ip: "203.0.113.4",
headers: { "user-agent": "TestAgent/1.0" },
},
});
expect(auditEntries[0]).toMatchObject({
ipAddress: "203.0.113.4",
userAgent: "TestAgent/1.0",
await new Promise((resolve) => setTimeout(resolve, 5));
stop();
emitAuditEntry({
userId: null,
username: "root",
action: "two",
resourceType: "x",
success: true,
});
await new Promise((resolve) => setTimeout(resolve, 5));
expect(seen).toEqual(["one"]);
});
it("falls back to the socket address on an upgrade request", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({
action: "collab_join",
it("stops delivering when the plugin deactivates", async () => {
grants.set("demo", ["audit:read"]);
const { ctx, handle } = contextFor("demo", ["audit:read"]);
const seen: string[] = [];
ctx.audit.subscribe((entry) => seen.push(entry.action));
await new Promise((resolve) => setTimeout(resolve, 20));
await handle.bag.disposeAll();
emitAuditEntry({
userId: null,
username: "root",
action: "late",
resourceType: "x",
success: true,
request: { headers: {}, socket: { remoteAddress: "198.51.100.9" } },
});
expect(auditEntries[0]).toMatchObject({ ipAddress: "198.51.100.9" });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(seen).toEqual([]);
});
});
@@ -0,0 +1,179 @@
/**
* ctx.credentials.publicKey and resolveKeyRefs, and the capability gate on
* ctx.ssh.exec.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import ssh2 from "ssh2";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
permissions: new Set<string>(["credentials.view"]),
credential: null as Record<string, unknown> | null,
resolve: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentHostResolutionRepository: () => ({
findCredentialByIdForUser: async () => state.credential,
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (_user: string, name: string) =>
state.permissions.has(name),
}),
},
}));
vi.mock("../../hosts/stored-ssh-key.js", () => ({
resolveKeyReferences: state.resolve,
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
return createPluginContext(
manifest,
createPluginHandle("demo", { activate: () => {} }),
);
}
const asUser = <T>(fn: () => Promise<T>) => runAsActor("user-1", "request", fn);
const pair = ssh2.utils.generateKeyPairSync("ed25519");
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
state.permissions = new Set(["credentials.view"]);
state.credential = null;
state.resolve.mockReset();
});
describe("ctx.credentials.publicKey", () => {
it("refuses without credentials:use", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
await expect(asUser(() => ctx.credentials.publicKey(1))).rejects.toThrow(
/credentials:use/,
);
});
it("refuses when the user lacks credentials.view", async () => {
grants.set("demo", ["credentials:use"]);
state.permissions = new Set();
const ctx = contextFor(["credentials:use"]);
await expect(asUser(() => ctx.credentials.publicKey(1))).rejects.toThrow(
/credentials.view/,
);
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
it("returns null for a missing credential or one that is not a key", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
expect(await asUser(() => ctx.credentials.publicKey(1))).toBeNull();
state.credential = { id: 1, authType: "password" };
expect(await asUser(() => ctx.credentials.publicKey(1))).toBeNull();
});
it("returns the stored public key", async () => {
grants.set("demo", ["credentials:use"]);
state.credential = {
id: 1,
authType: "key",
publicKey: " ssh-ed25519 AAA ",
};
const ctx = contextFor(["credentials:use"]);
expect(await asUser(() => ctx.credentials.publicKey(1))).toBe(
"ssh-ed25519 AAA",
);
});
it("derives it from the private key behind a reference", async () => {
grants.set("demo", ["credentials:use"]);
state.credential = { id: 1, authType: "key", key: "op://vault/key" };
state.resolve.mockResolvedValue({ key: pair.private });
const ctx = contextFor(["credentials:use"]);
const result = await asUser(() => ctx.credentials.publicKey(1));
expect(result).toMatch(/^ssh-ed25519 /);
expect(state.resolve).toHaveBeenCalledWith(
"user-1",
"op://vault/key",
null,
);
});
it("rejects a bad id", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
await expect(asUser(() => ctx.credentials.publicKey(0))).rejects.toThrow(
/credential id/,
);
});
});
describe("ctx.credentials.resolveKeyRefs", () => {
it("refuses with only credentials:use", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
await expect(
asUser(() => ctx.credentials.resolveKeyRefs("op://a")),
).rejects.toThrow(/credentials:read/);
expect(state.resolve).not.toHaveBeenCalled();
});
it("resolves for the acting user and audits", async () => {
grants.set("demo", ["credentials:read"]);
state.resolve.mockResolvedValue({ key: "KEY", passphrase: "PW" });
const ctx = contextFor(["credentials:read"]);
const result = await asUser(() =>
ctx.credentials.resolveKeyRefs("op://a", "op://b"),
);
expect(result).toEqual({ key: "KEY", passphrase: "PW" });
expect(state.resolve).toHaveBeenCalledWith("user-1", "op://a", "op://b");
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_credentials_resolve_key",
success: true,
});
});
});
describe("ctx.ssh.exec", () => {
it("refuses without ssh:connect and credentials:use", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
await expect(asUser(() => ctx.ssh.exec(1, "id"))).rejects.toThrow(
/ssh:connect/,
);
});
});
@@ -461,6 +461,40 @@ describe("ctx.credentials.registerSecretResolver", () => {
});
});
describe("ctx.credentials.list", () => {
beforeEach(() => {
state.userCredentials.set("owner", [
{ id: 3, name: "pw", authType: "password", password: "secret" },
{ id: 4, name: "key", authType: "key", privateKey: "PRIVATE KEY" },
]);
});
it("refuses without credentials:use", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
await expect(
runAsActor("owner", "request", () => ctx.credentials.list()),
).rejects.toThrow(/credentials:use/);
});
it("lists the actor's credentials without secrets", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
const list = await runAsActor("owner", "request", () =>
ctx.credentials.list(),
);
expect(list).toEqual([
{ id: 3, name: "pw", username: null, authType: "password", folder: null },
{ id: 4, name: "key", username: null, authType: "key", folder: null },
]);
expect(JSON.stringify(list)).not.toMatch(/secret|PRIVATE/);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_credentials_list",
success: true,
});
});
});
describe("ctx.credentials.listSshKeys", () => {
const pair = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 });
const derived = (() => {
@@ -0,0 +1,248 @@
/**
* ctx.desktop.local: desktop:local is checked and audited, and nothing
* touches the machine unless the backend runs embedded in the desktop app.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
const bridge = vi.hoisted(() => ({
requestFromElectronMain: vi.fn(async () => ({ success: true })),
}));
vi.mock("../../utils/electron-ipc-bridge.js", () => ({
isElectronIpcAvailable: () => process.env.ELECTRON_EMBEDDED === "true",
requestFromElectronMain: bridge.requestFromElectronMain,
}));
vi.mock("../../sync/client/link-store.js", () => ({
getLink: async () => ({
serverUrl: "https://termix.example.test",
sessionToken: "token",
}),
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return createPluginContext(manifest, handle);
}
let dir: string;
const previous = process.env.ELECTRON_EMBEDDED;
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
bridge.requestFromElectronMain.mockClear();
process.env.ELECTRON_EMBEDDED = "true";
dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-local-"));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
if (previous === undefined) delete process.env.ELECTRON_EMBEDDED;
else process.env.ELECTRON_EMBEDDED = previous;
});
describe("ctx.desktop.local", () => {
it("refuses without desktop:local and audits the refusal", async () => {
grants.set("demo", []);
const ctx = contextFor("demo", []);
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(
/desktop:local/,
);
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
it("refuses when granted but not declared", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", []);
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(
/desktop:local/,
);
});
it("refuses outside the desktop app even with the capability", async () => {
grants.set("demo", ["desktop:local"]);
process.env.ELECTRON_EMBEDDED = "false";
const ctx = contextFor("demo", ["desktop:local"]);
expect(ctx.desktop.local.available()).toBe(false);
await expect(ctx.desktop.local.fs.list(dir)).rejects.toThrow(/desktop app/);
});
it("lists and audits once per action", async () => {
grants.set("demo", ["desktop:local"]);
fs.writeFileSync(path.join(dir, "a.txt"), "hi");
const ctx = contextFor("demo", ["desktop:local"]);
const first = await ctx.desktop.local.fs.list(dir);
await ctx.desktop.local.fs.list(dir);
expect(first.entries.map((entry) => entry.name)).toEqual(["a.txt"]);
expect(
auditEntries.filter(
(entry) => entry.action === "plugin_desktop_local_fs",
),
).toHaveLength(1);
});
it("rejects relative paths and unsafe names", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
await expect(ctx.desktop.local.fs.list("relative/path")).rejects.toThrow(
/absolute/,
);
await expect(ctx.desktop.local.fs.mkdir(dir, "../evil")).rejects.toThrow(
/Invalid/,
);
});
it("never renames onto an existing name", async () => {
grants.set("demo", ["desktop:local"]);
fs.writeFileSync(path.join(dir, "a"), "1");
fs.writeFileSync(path.join(dir, "b"), "2");
const ctx = contextFor("demo", ["desktop:local"]);
await expect(
ctx.desktop.local.fs.rename(path.join(dir, "a"), "b"),
).rejects.toThrow(/already exists/);
expect(fs.readFileSync(path.join(dir, "b"), "utf8")).toBe("2");
});
it("keeps downloads inside the chosen folder and publishes on success", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
await expect(
ctx.desktop.local.fs.openDownload({
rootPath: dir,
destPath: path.join(dir, "..", "escape.txt"),
transferId: "t1",
}),
).rejects.toThrow(/outside/);
const download = await ctx.desktop.local.fs.openDownload({
rootPath: dir,
destPath: path.join(dir, "ok.txt"),
transferId: "t2",
});
download.stream.write("data");
expect(await download.publish()).toBe(path.join(dir, "ok.txt"));
expect(fs.readFileSync(path.join(dir, "ok.txt"), "utf8")).toBe("data");
expect(fs.readdirSync(dir)).toEqual(["ok.txt"]);
});
it("removes the partial file when a download is aborted", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
const download = await ctx.desktop.local.fs.openDownload({
rootPath: dir,
destPath: path.join(dir, "gone.txt"),
transferId: "t3",
});
download.stream.write("partial");
await download.abort();
expect(fs.readdirSync(dir)).toEqual([]);
});
it("sends trash and open through Electron main and audits each call", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
const target = path.join(dir, "x.txt");
fs.writeFileSync(target, "x");
await expect(ctx.desktop.local.fs.trash([target])).resolves.toEqual({
trashed: 1,
failed: [],
});
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
"shell-trash-item",
{ path: target },
);
await ctx.desktop.local.fs.open(target);
await ctx.desktop.local.fs.open(target);
expect(
auditEntries.filter(
(entry) => entry.action === "plugin_desktop_local_open",
),
).toHaveLength(2);
});
it("only calls the plugin's own routes", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
for (const bad of [
"relative",
"//evil.example/x",
"/../other",
"/a/../../b",
String.raw`/a\b`,
]) {
await expect(ctx.desktop.local.pluginFetch("local", bad)).rejects.toThrow(
/Invalid plugin route path/,
);
}
await expect(
ctx.desktop.local.pluginFetch("elsewhere" as never, "/ok"),
).rejects.toThrow(/Unknown origin|No acting user/);
});
it("reports the link and the embedded backend port", async () => {
grants.set("demo", ["desktop:local"]);
process.env.EMBEDDED_BACKEND_PORT = "31234";
const ctx = contextFor("demo", ["desktop:local"]);
const info = await ctx.desktop.local.info();
expect(info.backendPort).toBe(31234);
expect(info.link).toMatchObject({
serverUrl: "https://termix.example.test",
token: "token",
allowInvalidCertificate: false,
});
expect(info.link?.headers.Cookie).toBe("jwt=token");
delete process.env.EMBEDDED_BACKEND_PORT;
});
});
@@ -0,0 +1,264 @@
/**
* ctx.hosts.importMany, importSshConfig, exportMany, registerImportNormalizer:
* capability checks, core permission checks, audit lines and the registry key.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
permissions: new Set<string>(["hosts.create", "hosts.edit"]),
}));
const importer = vi.hoisted(() => ({
runBulkImport: vi.fn(),
runSshConfigImport: vi.fn(),
exportAllHosts: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (_user: string, name: string) =>
state.permissions.has(name),
}),
},
}));
vi.mock("../../hosts/import/host-import.js", () => ({
runBulkImport: importer.runBulkImport,
runSshConfigImport: importer.runSshConfigImport,
}));
vi.mock("../../hosts/import/host-export.js", () => ({
exportAllHosts: importer.exportAllHosts,
}));
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { batched: (fn: () => unknown) => fn },
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import * as registry from "../../plugins/registry.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle(pluginId, { activate: () => {} });
return { ctx: createPluginContext(manifest, handle), handle };
}
const asUser = <T>(fn: () => Promise<T>) => runAsActor("user-1", "request", fn);
const outcome = {
success: 1,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
createdHostIds: [5],
};
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
state.permissions = new Set(["hosts.create", "hosts.edit"]);
importer.runBulkImport.mockReset().mockResolvedValue(outcome);
importer.runSshConfigImport.mockReset().mockResolvedValue(outcome);
importer.exportAllHosts.mockReset().mockResolvedValue({ hosts: [{ id: 1 }] });
});
describe("ctx.hosts.importMany", () => {
it("refuses without hosts:write", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(
asUser(() => ctx.hosts.importMany([{ ip: "a" }])),
).rejects.toThrow(/hosts:write/);
expect(importer.runBulkImport).not.toHaveBeenCalled();
});
it("needs credentials:write only when credentials are passed", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(
asUser(() => ctx.hosts.importMany([{ ip: "a" }])),
).resolves.toMatchObject({ success: 1 });
await expect(
asUser(() =>
ctx.hosts.importMany([{ ip: "a" }], { credentials: [{ alias: "k" }] }),
),
).rejects.toThrow(/credentials:write/);
});
it("refuses when the acting user lacks the core permissions", async () => {
grants.set("demo", ["hosts:write"]);
state.permissions = new Set(["hosts.create"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(
asUser(() => ctx.hosts.importMany([{ ip: "a" }])),
).rejects.toThrow(/hosts.edit/);
expect(importer.runBulkImport).not.toHaveBeenCalled();
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_hosts_import",
success: false,
});
});
it("needs an acting user", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(ctx.hosts.importMany([{ ip: "a" }])).rejects.toThrow(
/acting user/,
);
});
it("passes the options through and audits the counts", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
const result = await asUser(() =>
ctx.hosts.importMany([{ ip: "a" }], { atomic: true }),
);
expect(result.createdHostIds).toEqual([5]);
expect(importer.runBulkImport).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ atomic: true, hosts: [{ ip: "a" }] }),
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_hosts_import",
success: true,
});
});
it("surfaces the importer's request errors and audits the failure", async () => {
grants.set("demo", ["hosts:write"]);
importer.runBulkImport.mockRejectedValue(new Error("Maximum 100 hosts"));
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(asUser(() => ctx.hosts.importMany([]))).rejects.toThrow(
/Maximum 100/,
);
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
});
describe("ctx.hosts.importSshConfig", () => {
it("refuses without hosts:write and runs with it", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(
asUser(() => ctx.hosts.importSshConfig("Host a")),
).rejects.toThrow(/hosts:write/);
grants.set("demo", ["hosts:write"]);
invalidatePluginPermissionCache();
const { ctx: allowed } = contextFor("demo", ["hosts:write"]);
await asUser(() => allowed.hosts.importSshConfig("Host a"));
expect(importer.runSshConfigImport).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ content: "Host a" }),
);
});
});
describe("ctx.hosts.exportMany", () => {
it("refuses with only hosts:write and audits the refusal", async () => {
grants.set("demo", ["hosts:write"]);
const { ctx } = contextFor("demo", ["hosts:write"]);
await expect(asUser(() => ctx.hosts.exportMany())).rejects.toThrow(
/hosts:export/,
);
expect(importer.exportAllHosts).not.toHaveBeenCalled();
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
it("exports with secrets by default and audits it", async () => {
grants.set("demo", ["hosts:export"]);
const { ctx } = contextFor("demo", ["hosts:export"]);
await expect(asUser(() => ctx.hosts.exportMany())).resolves.toEqual({
hosts: [{ id: 1 }],
});
expect(importer.exportAllHosts).toHaveBeenCalledWith(
"user-1",
false,
undefined,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_export_hosts",
success: true,
});
expect(String(auditEntries.at(-1)?.details)).toMatch(/with secrets/);
});
it("passes share mode through", async () => {
grants.set("demo", ["hosts:export"]);
const { ctx } = contextFor("demo", ["hosts:export"]);
await asUser(() => ctx.hosts.exportMany({ share: true }));
expect(importer.exportAllHosts).toHaveBeenCalledWith(
"user-1",
true,
undefined,
);
expect(String(auditEntries.at(-1)?.details)).toMatch(/without secrets/);
});
it("passes the host ids filter through", async () => {
grants.set("demo", ["hosts:export"]);
const { ctx } = contextFor("demo", ["hosts:export"]);
await asUser(() => ctx.hosts.exportMany({ hostIds: [4, 5] }));
expect(importer.exportAllHosts).toHaveBeenCalledWith(
"user-1",
false,
[4, 5],
);
});
});
describe("ctx.hosts.registerImportNormalizer", () => {
it("needs hosts:write", () => {
const { ctx } = contextFor("demo", []);
expect(() => ctx.hosts.registerImportNormalizer(() => null)).toThrow(
/hosts:write/,
);
});
it("registers under the key the import path reads and removes it on deactivate", async () => {
const { ctx, handle } = contextFor("demo", ["hosts:write"]);
const normalizer = () => ({ a: 1 });
ctx.hosts.registerImportNormalizer(normalizer);
expect(registry.consume("demo.hostImportNormalizer")).toBe(normalizer);
await handle.bag.disposeAll();
expect(registry.consume("demo.hostImportNormalizer")).toBeUndefined();
});
it("returns a remover", () => {
const { ctx } = contextFor("demo", ["hosts:write"]);
const remove = ctx.hosts.registerImportNormalizer(() => null);
remove();
expect(registry.consume("demo.hostImportNormalizer")).toBeUndefined();
});
});
+319 -9
View File
@@ -27,6 +27,11 @@ const h = vi.hoisted(() => ({
statusPortsRemoved: [] as string[],
userPermissions: new Set<string>(),
deleted: [] as Array<{ userId: string; hostId: number }>,
existingAccess: [] as Array<{ id: number }>,
revoked: [] as number[],
protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>,
folderCalls: [] as Array<unknown[]>,
folderEmpty: true,
roles: [] as Array<{
id: number;
name: string;
@@ -85,10 +90,23 @@ vi.mock("../../database/repositories/factory.js", () => ({
h.upserts.push(input);
return { id: h.upserts.length, created: true };
},
listHostAccess: async () => h.existingAccess,
revokeHostAccess: async (accessId: number) => {
h.revoked.push(accessId);
},
}),
createCurrentUserRepository: () => ({
listAll: async () => h.users,
}),
createCurrentHostFolderRepository: () => ({
upsertMetadata: async (...args: unknown[]) => {
h.folderCalls.push(["ensure", ...args]);
},
deleteEmptyFolderRecords: async (...args: unknown[]) => {
h.folderCalls.push(["remove", ...args]);
return h.folderEmpty;
},
}),
createCurrentHostRepository: () => ({
createEncryptedForUser: async (
userId: string,
@@ -110,14 +128,14 @@ vi.mock("../../database/repositories/factory.js", () => ({
listDecryptedByUserId: async () => h.ownedHosts,
}),
}));
vi.mock("../../services/recent-activity.js", () => ({
recordRecentActivity: async (
userId: string,
entry: Record<string, unknown>,
) => {
h.activities.push({ userId, ...entry });
return { status: "logged", id: 1 };
vi.mock("../../plugins/activity-provider.js", () => ({
recentActivity: {
record: async (userId: string, entry: Record<string, unknown>) => {
h.activities.push({ userId, ...entry });
return { status: "logged", id: 1 };
},
},
provideActivity: vi.fn(() => () => {}),
}));
vi.mock("../../hosts/host-session-status.js", () => ({
hostSessionStatus: {
@@ -127,8 +145,9 @@ vi.mock("../../hosts/host-session-status.js", () => ({
},
},
}));
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
vi.mock("../../plugins/host-status-provider.js", () => ({
provideHostStatus: vi.fn(() => () => {}),
hostStatus: {
get: (hostId: number) =>
hostId === 4 ? { status: "online", lastChecked: "t" } : null,
check: async (hostId: number) => ({
@@ -144,6 +163,13 @@ vi.mock("../../hosts/status/host-status-service.js", () => ({
},
}));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => [
{
id: "metrics",
contributes: { settings: { host: { enableKey: "enableMetrics" } } },
},
{ id: "noswitch", contributes: { settings: { host: { fields: [] } } } },
],
loadHostPluginSettings: async (hostIds: number[]) =>
new Map(
hostIds.map((id) => [
@@ -197,6 +223,25 @@ vi.mock("../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
}));
vi.mock("../../database/routes/rbac.js", () => ({
pruneHostGrants: async () => 0,
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
credentialId === 5 ? { id: 5 } : null,
}));
vi.mock("../../hosts/protocol-auth/protocol-auth.js", () => ({
readProtocolAuthPayload: (data: { protocolAuth: Record<string, unknown> }) =>
new Map(Object.entries(data.protocolAuth)),
writeProtocolAuth: async (
_ownerId: string,
hostId: number,
patch: Map<string, unknown>,
) => {
h.protocolWrites.push({ hostId, protocols: [...patch.keys()] });
},
}));
vi.mock("../../hosts/delete-host.js", () => ({
deleteOwnedHost: async (userId: string, hostId: number) => {
h.deleted.push({ userId, hostId });
@@ -208,6 +253,8 @@ const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
const { clearHostLocks, hostLock, provideHostLocks } =
await import("../../hosts/locks.js");
function manifest(capabilities: string[]) {
return {
@@ -235,6 +282,10 @@ beforeEach(() => {
h.roles = [];
h.userPermissions = new Set();
h.deleted = [];
h.existingAccess = [];
h.revoked = [];
h.protocolWrites = [];
clearHostLocks();
});
describe("ctx.hosts", () => {
@@ -704,3 +755,262 @@ describe("ctx.hosts.status", () => {
expect(h.statusPortsRemoved).toEqual(["rdp"]);
});
});
describe("ctx.hosts secrets and protocol logins", () => {
it("stores secrets on create and writes protocol logins", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
const created = await hosts.create({
name: "rdp",
ip: "10.0.0.9",
port: 3389,
username: "",
authType: "password",
password: "s3cret",
sudoPassword: "",
protocolAuth: { rdp: { authType: "direct", username: "admin" } },
});
expect(h.created[0]).toMatchObject({
password: "s3cret",
sudoPassword: null,
});
expect(created).not.toHaveProperty("password");
expect(h.protocolWrites).toEqual([{ hostId: 99, protocols: ["rdp"] }]);
});
it("refuses a protocol credential the owner cannot use", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(
hosts.create({
name: "vnc",
ip: "10.0.0.9",
port: 5900,
username: "",
authType: "password",
protocolAuth: { vnc: { authType: "credential", credentialId: 6 } },
}),
).rejects.toThrow(/credential 6/);
});
it("refuses a secret that is not a string", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(hosts.update(5, { password: 12 as never })).rejects.toThrow(
/password/,
);
});
});
describe("ctx.hosts pluginEnable", () => {
it("writes each plugin's switch under its enableKey", async () => {
h.granted = new Set(["hosts:write"]);
h.importRows = [];
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await hosts.create({
name: "n",
ip: "10.0.0.1",
port: 22,
username: "root",
authType: "password",
pluginEnable: { metrics: false },
});
expect(h.importRows).toEqual([
{ hostId: 99, row: { metrics: { enableMetrics: false } } },
]);
});
it("refuses a plugin with no switch", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(
hosts.update(5, { pluginEnable: { noswitch: true } }),
).rejects.toThrow(/no host switch/);
expect(h.updates).toHaveLength(0);
});
});
describe("ctx.hosts.setShares", () => {
it("upserts the listed grants and revokes the rest", async () => {
h.granted = new Set(["hosts:write"]);
h.existingAccess = [{ id: 1 }, { id: 7 }];
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
const result = await hosts.setShares(3, [
{ target: { type: "role", id: 2 }, permissionLevel: "connect" },
]);
expect(result).toEqual({ hostId: 3, shared: true });
expect(h.upserts).toEqual([
expect.objectContaining({
hostId: 3,
targetType: "role",
targetRoleId: 2,
permissionLevel: "connect",
expiresAt: null,
}),
]);
expect(h.revoked).toEqual([7]);
});
it("only works for the host owner", async () => {
h.granted = new Set(["hosts:write"]);
h.ownerId = "someone-else";
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
expect(await hosts.setShares(3, [])).toEqual({
hostId: 3,
shared: false,
reason: "forbidden",
});
expect(h.revoked).toEqual([]);
});
it("refuses a bad level", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(
hosts.setShares(3, [
{ target: { type: "user", id: "u2" }, permissionLevel: "own" as never },
]),
).rejects.toThrow(/share level/);
});
});
describe("ctx.hosts.locks", () => {
const provider = (locked: number[], allowManualCreate = true) => ({
isLocked: (hostId: number) => locked.includes(hostId),
note: () => "From hosts.yaml",
allowManualCreate: () => allowManualCreate,
});
it("needs hosts:lock", () => {
const hosts = createPluginHosts({
manifest: manifest([]),
audit: vi.fn(async () => {}),
});
expect(() => hosts.locks.provide(provider([1]))).toThrow(
PluginCapabilityError,
);
});
it("locks hosts until the plugin removes its provider", () => {
const hosts = createPluginHosts({
manifest: manifest(["hosts:lock"]),
audit: vi.fn(async () => {}),
});
const remove = hosts.locks.provide(provider([4]));
expect(hostLock(4)).toEqual({
pluginId: "fixture",
pluginName: "Fixture",
note: "From hosts.yaml",
});
expect(hostLock(5)).toBeNull();
remove();
expect(hostLock(4)).toBeNull();
});
it("blocks other plugins on a locked host but not the plugin that locks it", async () => {
h.granted = new Set(["hosts:write"]);
h.updateResult = { id: 4, name: "x" };
h.userPermissions = new Set(["hosts.delete"]);
provideHostLocks("hosts-file", "Hosts File", provider([4], false));
const other = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(other.update(4, { name: "y" })).rejects.toThrow(
/managed by Hosts File/,
);
await expect(other.delete(4)).rejects.toThrow(/managed by Hosts File/);
await expect(
other.share(4, [{ type: "user", id: "u2" }], "view"),
).rejects.toThrow(/managed by Hosts File/);
await expect(
other.create({
name: "n",
ip: "10.0.0.1",
port: 22,
username: "root",
authType: "password",
}),
).rejects.toThrow(/new hosts can't be added/);
expect(h.updates).toHaveLength(0);
expect(h.deleted).toHaveLength(0);
const owner = createPluginHosts({
manifest: {
id: "hosts-file",
name: "Hosts File",
version: "1.0.0",
capabilities: ["hosts:write"],
} as never,
audit: vi.fn(async () => {}),
});
await owner.update(4, { name: "y" });
expect(h.updates).toHaveLength(1);
});
});
describe("ctx.hosts.folders", () => {
beforeEach(() => {
h.granted = new Set(["hosts:write"]);
h.actor = "user-1";
h.folderCalls = [];
h.folderEmpty = true;
});
it("needs hosts:write", async () => {
h.granted = new Set();
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(hosts.folders.ensure("Site")).rejects.toThrow(
PluginCapabilityError,
);
});
it("creates the folder row for the acting user with its style", async () => {
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await hosts.folders.ensure("Site", { icon: "cloud-cog" });
expect(h.folderCalls).toEqual([
["ensure", "user-1", "Site", null, "cloud-cog"],
]);
});
it("removes only an empty folder", async () => {
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
expect(await hosts.folders.removeEmpty("Site")).toBe(true);
h.folderEmpty = false;
expect(await hosts.folders.removeEmpty("Site")).toBe(false);
expect(h.folderCalls[0]).toEqual(["remove", "user-1", "Site"]);
});
});
+182
View File
@@ -0,0 +1,182 @@
/**
* ctx.network.tcpProbe, ctx.ssh.probe and ctx.hosts.probeTargets: gated by
* capability, and only ever offered hosts the actor may connect to.
*/
import net from "node:net";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const state = vi.hoisted(() => ({
owned: [] as Array<Record<string, unknown>>,
shared: [] as Array<Record<string, unknown>>,
sharedIds: [] as number[],
allowed: new Set<number>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentHostResolutionRepository: () => ({
listStatusTargets: async (filter: {
userId?: string;
hostIds?: number[];
}) => {
const rows = filter.userId ? state.owned : state.shared;
return rows.filter(
(row) => !filter.hostIds || filter.hostIds.includes(row.id as number),
);
},
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentRbacAccessRepository: () => ({
listVisibleHostAccessEntries: async () =>
state.sharedIds.map((hostId) => ({ hostId })),
}),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
filterAccessibleHostIds: async (_user: string, ids: number[]) =>
new Set(ids.filter((id) => state.allowed.has(id))),
canAccessHost: async (_user: string, id: number) => ({
hasAccess: state.allowed.has(id),
}),
}),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: async () => {} }));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.10.3", api: "1" },
capabilities,
} as PluginManifest;
return createPluginContext(
manifest,
createPluginHandle("demo", { activate: () => {} }),
);
}
const row = (id: number, userId: string) => ({
id,
userId,
ip: "127.0.0.1",
port: 22,
connectionType: "ssh",
jumpHosts: null,
statusCheckEnabled: true,
statusCheckInterval: null,
});
let server: net.Server;
let port = 0;
beforeEach(async () => {
grants.clear();
invalidatePluginPermissionCache();
state.owned = [row(1, "alice")];
state.shared = [row(2, "bob"), row(3, "bob")];
state.sharedIds = [2, 3];
state.allowed = new Set([2]);
server = net.createServer((socket) => socket.destroy());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
port = (server.address() as AddressInfo).port;
});
afterEach(() => {
server.close();
});
describe("ctx.network.tcpProbe", () => {
it("refuses without network:probe", async () => {
grants.set("demo", []);
await expect(
contextFor([]).network.tcpProbe("127.0.0.1", port),
).rejects.toThrow(/network:probe/);
});
it("reports an open port and a closed one", async () => {
grants.set("demo", ["network:probe"]);
const ctx = contextFor(["network:probe"]);
expect(await ctx.network.tcpProbe("127.0.0.1", port)).toBe(true);
await new Promise<void>((resolve) => server.close(() => resolve()));
expect(
await ctx.network.tcpProbe("127.0.0.1", port, { timeoutMs: 500 }),
).toBe(false);
});
it("rejects a bad port", async () => {
grants.set("demo", ["network:probe"]);
await expect(
contextFor(["network:probe"]).network.tcpProbe("127.0.0.1", 0),
).rejects.toThrow(/port/);
});
});
describe("ctx.hosts.probeTargets", () => {
it("refuses without hosts:read", async () => {
grants.set("demo", []);
await expect(contextFor([]).hosts.probeTargets()).rejects.toThrow(
/hosts:read/,
);
});
it("needs an acting user", async () => {
grants.set("demo", ["hosts:read"]);
await expect(
contextFor(["hosts:read"]).hosts.probeTargets(),
).rejects.toThrow(/acting user/);
});
it("returns own hosts and only shared hosts the actor may connect to", async () => {
grants.set("demo", ["hosts:read"]);
const ctx = contextFor(["hosts:read"]);
const targets = await runAsActor("alice", "request", () =>
ctx.hosts.probeTargets(),
);
expect(targets.map((target) => target.id)).toEqual([1, 2]);
expect(targets[1].ownerUserId).toBe("bob");
});
});
describe("ctx.ssh.probe", () => {
it("refuses without ssh:connect", async () => {
grants.set("demo", []);
await expect(contextFor([]).ssh.probe(1)).rejects.toThrow(/ssh:connect/);
});
it("answers false for a host the actor cannot connect to", async () => {
grants.set("demo", ["ssh:connect"]);
const ctx = contextFor(["ssh:connect"]);
expect(await runAsActor("alice", "request", () => ctx.ssh.probe(3))).toBe(
false,
);
});
it("probes a reachable host directly", async () => {
grants.set("demo", ["ssh:connect"]);
const ctx = contextFor(["ssh:connect"]);
expect(
await runAsActor("alice", "request", () => ctx.ssh.probe(2, { port })),
).toBe(true);
});
});
+64 -2
View File
@@ -12,6 +12,7 @@ const h = vi.hoisted(() => ({
sessionId: undefined as string | undefined,
revoked: [] as Array<{ userId: string; except?: string }>,
trustedCleared: [] as string[],
unrestricted: [] as string[],
policyError: null as Error | null,
logins: [] as Array<{ hostId: number; outcome: unknown }>,
connectError: null as Error | null,
@@ -104,8 +105,8 @@ vi.mock("../../hosts/host-resolver.js", () => ({
}),
resolveHostBySyncId: async () => null,
}));
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
vi.mock("../../plugins/host-status-provider.js", () => ({
hostStatus: {
reportLogin: (hostId: number, outcome: unknown) =>
h.logins.push({ hostId, outcome }),
},
@@ -126,6 +127,9 @@ vi.mock("../../database/repositories/factory.js", () => ({
h.revoked.push({ userId, except });
return 0;
},
clearRestrictionForUser: async (userId: string) => {
h.unrestricted.push(userId);
},
}),
createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async (userId: string) => {
@@ -145,6 +149,8 @@ const { getLoginMethod, getSecondFactor } =
await import("../../auth/registry.js");
const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend");
const { hasAuthPolicies, clearAuthPolicies } =
await import("../../auth/policy.js");
function manifest(capabilities: string[], auth: Record<string, string[]> = {}) {
return {
@@ -655,6 +661,7 @@ describe("ctx.auth", () => {
h.factors = [];
h.revoked = [];
h.trustedCleared = [];
h.unrestricted = [];
h.policyError = null;
h.actor = "user-1";
h.sessionId = "session-a";
@@ -666,10 +673,65 @@ describe("ctx.auth", () => {
await auth.recordEnrollment("user-1", "pin");
expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]);
expect(h.trustedCleared).toEqual(["user-1"]);
expect(h.unrestricted).toEqual(["user-1"]);
h.actor = undefined;
h.sessionId = undefined;
});
it("needs auth:policy for a login policy and drops it on deactivate", async () => {
clearAuthPolicies();
const plain = createPluginAuth({
manifest: manifest(["auth:provide"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
expect(() => plain.policy.provide({ evaluate: () => ({}) })).toThrow(
PluginCapabilityError,
);
const bag = new DisposableBag("fixture");
const audit = vi.fn(async () => {});
const auth = createPluginAuth({
manifest: manifest(["auth:policy"]),
bag,
audit,
});
const remove = auth.policy.provide({ evaluate: () => ({}) });
expect(hasAuthPolicies()).toBe(true);
expect(audit).toHaveBeenCalledWith(
"auth_policy_provide",
"fixture sets login rules",
{ success: true },
);
remove();
expect(hasAuthPolicies()).toBe(false);
auth.policy.provide({ evaluate: () => ({}) });
await bag.disposeAll();
expect(hasAuthPolicies()).toBe(false);
});
it("lists second factors from every plugin", async () => {
h.granted = new Set(["auth:provide"]);
const bag = new DisposableBag("fixture");
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
bag,
audit: vi.fn(async () => {}),
});
auth.registerSecondFactor({
id: "pin",
labelKey: "pin",
isEnrolled: async () => false,
verify: async () => true,
});
expect(auth.listSecondFactors()).toContainEqual({
id: "pin",
pluginId: "fixture",
});
await bag.disposeAll();
});
it("refuses enrolment when core policy forbids second factors", async () => {
h.granted = new Set(["auth:provide"]);
h.factors = [];
@@ -0,0 +1,104 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runExec, type ExecClient } from "../../plugins/ctx-ssh-exec.js";
function fakeClient(
script: (stream: FakeStream) => void,
execError?: Error,
): ExecClient & { commands: string[] } {
const commands: string[] = [];
return {
commands,
exec: (command, callback) => {
commands.push(command);
if (execError) return callback(execError, undefined as never);
const stream = new FakeStream();
callback(undefined, stream as never);
script(stream);
},
};
}
class FakeStream extends EventEmitter {
stderr = new EventEmitter();
written: string[] = [];
closed = false;
write(data: string) {
this.written.push(data);
}
end(data?: string) {
if (data !== undefined) this.written.push(data);
}
close() {
this.closed = true;
}
}
afterEach(() => {
vi.useRealTimers();
});
describe("runExec", () => {
it("collects output and the exit code", async () => {
const client = fakeClient((stream) => {
stream.emit("data", Buffer.from("out"));
stream.stderr.emit("data", Buffer.from("err"));
stream.emit("close", 3);
});
await expect(runExec(client, "echo hi")).resolves.toEqual({
stdout: "out",
stderr: "err",
code: 3,
truncated: false,
timedOut: false,
});
expect(client.commands).toEqual(["echo hi"]);
});
it("keeps only maxOutputBytes and says so", async () => {
const client = fakeClient((stream) => {
stream.emit("data", Buffer.alloc(3000, "a"));
stream.emit("close", 0);
});
const result = await runExec(client, "yes", { maxOutputBytes: 1024 });
expect(result.stdout).toHaveLength(1024);
expect(result.truncated).toBe(true);
});
it("writes stdin and closes it", async () => {
let seen: FakeStream | undefined;
const client = fakeClient((stream) => {
seen = stream;
stream.emit("close", 0);
});
await runExec(client, "cat >> f", { stdin: "key\n" });
expect(seen?.written).toEqual(["key\n"]);
});
it("stops a command that runs past the time limit", async () => {
vi.useFakeTimers();
let seen: FakeStream | undefined;
const client = fakeClient((stream) => {
seen = stream;
});
const pending = runExec(client, "sleep 999", { timeoutMs: 2000 });
await vi.advanceTimersByTimeAsync(2100);
const result = await pending;
expect(result).toMatchObject({ code: null, timedOut: true });
expect(seen?.closed).toBe(true);
});
it("rejects when the exec itself fails", async () => {
const client = fakeClient(() => {}, new Error("channel open failure"));
await expect(runExec(client, "ls")).rejects.toThrow(/channel open/);
});
it("rejects an empty or oversized command before running it", async () => {
const client = fakeClient(() => {});
await expect(runExec(client, " ")).rejects.toThrow(/required/);
await expect(runExec(client, "x".repeat(70_000))).rejects.toThrow(
/too long/,
);
expect(client.commands).toEqual([]);
});
});
@@ -0,0 +1,483 @@
/**
* The local file helpers behind ctx.desktop.local.fs. Ported from the old
* Electron local-files tests: path containment, link refusal, publish and
* replace semantics, and the exclusive rename.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assertWithinRoot,
createEmptyFile,
defaultPublishFs,
ensureDirectory,
existingPaths,
listDirectory,
makeDirectory,
openDownload,
prepareDownloadPath,
publishDownload,
renameEntry,
renameLocalPath,
walkPaths,
type PublishFs,
} from "../../plugins/desktop-local/local-fs.js";
let root: string;
beforeEach(async () => {
root = await fsp.mkdtemp(path.join(os.tmpdir(), "termix-desktop-local-"));
});
afterEach(() => fsp.rm(root, { recursive: true, force: true }));
function windowsLikeFs(
overrides: Partial<PublishFs> = {},
): PublishFs & { renames: Array<[string, string]> } {
const renames: Array<[string, string]> = [];
return {
...defaultPublishFs,
renames,
rename: async (from, to) => {
renames.push([from, to]);
if (fs.existsSync(to)) {
throw Object.assign(new Error("EEXIST: file already exists, rename"), {
code: "EEXIST",
});
}
await defaultPublishFs.rename(from, to);
},
...overrides,
};
}
describe("download destination containment", () => {
const win = path.win32;
const winRoot = "C:\\Downloads\\selected";
it("accepts ordinary nested destinations on Windows", () => {
expect(
assertWithinRoot(winRoot, "C:\\Downloads\\selected\\a.txt", win),
).toBe("C:\\Downloads\\selected\\a.txt");
expect(
assertWithinRoot(
winRoot,
"C:\\Downloads\\selected\\docs\\2026\\report.pdf",
win,
),
).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf");
expect(
assertWithinRoot(winRoot, "c:\\downloads\\SELECTED\\b.txt", win),
).toBe("c:\\downloads\\SELECTED\\b.txt");
});
it("rejects backslash traversal that normalises out of the root on Windows", () => {
expect(() =>
assertWithinRoot(
winRoot,
"C:\\Downloads\\selected\\..\\outside.txt",
win,
),
).toThrow(/outside the selected folder/);
expect(() =>
assertWithinRoot(
winRoot,
"C:\\Downloads\\selected\\sub\\..\\..\\..\\x.txt",
win,
),
).toThrow(/outside the selected folder/);
expect(() => assertWithinRoot(winRoot, winRoot, win)).toThrow(
/outside the selected folder/,
);
});
it("rejects absolute, drive-qualified and UNC destinations on Windows", () => {
for (const dest of [
"C:\\Windows\\evil.dll",
"D:\\Downloads\\selected\\x.txt",
"\\\\server\\share\\x.txt",
"C:\\Downloads\\selected-other\\x.txt",
]) {
expect(() => assertWithinRoot(winRoot, dest, win)).toThrow(
/outside the selected folder/,
);
}
});
it("requires a root and rejects POSIX traversal too", () => {
expect(() => assertWithinRoot("", "/tmp/x", path.posix)).toThrow(
/download folder is required/,
);
expect(() =>
assertWithinRoot("/home/max/dl", "/home/max/dl/../x", path.posix),
).toThrow(/outside the selected folder/);
expect(
assertWithinRoot("/home/max/dl", "/home/max/dl/..\\x", path.posix),
).toBe("/home/max/dl/..\\x");
});
it("refuses an escaping download before any disk write", async () => {
const selected = path.join(root, "selected");
await fsp.mkdir(selected);
await expect(
openDownload({
rootPath: selected,
destPath: path.join(selected, "..", "outside.txt"),
transferId: "e1",
}),
).rejects.toMatchObject({ code: "EINVAL" });
await expect(
ensureDirectory(path.join(selected, "..", "escaped-dir"), selected),
).rejects.toMatchObject({ code: "EINVAL" });
await expect(
openDownload({
rootPath: "",
destPath: path.join(selected, "ok.bin"),
transferId: "e2",
}),
).rejects.toMatchObject({ code: "EINVAL" });
expect(await fsp.readdir(root)).toEqual(["selected"]);
expect(await fsp.readdir(selected)).toEqual([]);
});
});
describe("links inside a download tree", () => {
it("rejects existing linked parents for downloads and directory creation", async () => {
const selected = path.join(root, "selected");
const outside = path.join(root, "outside");
await fsp.mkdir(selected);
await fsp.mkdir(outside);
await fsp.symlink(outside, path.join(selected, "linked"), "junction");
await expect(
openDownload({
rootPath: selected,
destPath: path.join(selected, "linked", "file.bin"),
transferId: "l1",
}),
).rejects.toThrow(/cannot follow links/);
await expect(
ensureDirectory(path.join(selected, "linked", "new"), selected),
).rejects.toThrow(/cannot follow links/);
expect(await fsp.readdir(outside)).toEqual([]);
});
it("allows an explicitly selected linked root", async () => {
const actual = path.join(root, "actual");
const selected = path.join(root, "selected");
await fsp.mkdir(actual);
await fsp.symlink(actual, selected, "junction");
const download = await openDownload({
rootPath: selected,
destPath: path.join(selected, "nested", "file.bin"),
transferId: "l2",
});
download.stream.write("data");
await download.publish();
expect(
await fsp.readFile(path.join(actual, "nested", "file.bin"), "utf8"),
).toBe("data");
});
it("rejects a destination parent replaced by a link before publish", async () => {
const selected = path.join(root, "selected");
const nested = path.join(selected, "nested");
const outside = path.join(root, "outside");
await fsp.mkdir(nested, { recursive: true });
await fsp.mkdir(outside);
const download = await openDownload({
rootPath: selected,
destPath: path.join(nested, "file.bin"),
transferId: "l3",
});
download.stream.write("data");
await fsp.rename(nested, path.join(selected, "original"));
await fsp.symlink(outside, nested, "junction");
await expect(download.publish()).rejects.toThrow(/cannot follow links/);
expect(await fsp.readdir(outside)).toEqual([]);
});
it("prepareDownloadPath refuses a root that is not a directory", async () => {
const file = path.join(root, "file");
await fsp.writeFile(file, "x");
await expect(
prepareDownloadPath(file, path.join(file, "child")),
).rejects.toBeTruthy();
});
});
describe("downloads", () => {
it("refuses to replace an existing file by default", async () => {
const dest = path.join(root, "keep.bin");
await fsp.writeFile(dest, "original");
await expect(
openDownload({ rootPath: root, destPath: dest, transferId: "d1" }),
).rejects.toMatchObject({ code: "EEXIST" });
expect(await fsp.readFile(dest, "utf8")).toBe("original");
expect(await fsp.readdir(root)).toEqual(["keep.bin"]);
});
it("replaces an existing file only when overwrite is requested", async () => {
const dest = path.join(root, "replace.bin");
await fsp.writeFile(dest, "original");
const download = await openDownload({
rootPath: root,
destPath: dest,
transferId: "d2",
overwrite: true,
});
download.stream.write("fresh");
await download.publish();
expect(await fsp.readFile(dest, "utf8")).toBe("fresh");
expect(await fsp.readdir(root)).toEqual(["replace.bin"]);
});
it("refuses to publish over a file that appeared while it ran", async () => {
const dest = path.join(root, "race.bin");
const download = await openDownload({
rootPath: root,
destPath: dest,
transferId: "d3",
});
download.stream.write("mine");
await fsp.writeFile(dest, "someone else wrote this");
await expect(download.publish()).rejects.toMatchObject({ code: "EEXIST" });
expect(await fsp.readFile(dest, "utf8")).toBe("someone else wrote this");
expect(await fsp.readdir(root)).toEqual(["race.bin"]);
});
it("uses a transfer-unique partial and rejects a concurrent download to the same destination", async () => {
const dest = path.join(root, "same.bin");
const first = await openDownload({
rootPath: root,
destPath: dest,
transferId: "first",
});
await expect(
openDownload({ rootPath: root, destPath: dest, transferId: "second" }),
).rejects.toMatchObject({ code: "EBUSY" });
expect(
(await fsp.readdir(root)).filter((n) => n.endsWith(".termix-part")),
).toEqual(["same.bin.first.termix-part"]);
first.stream.write("done");
await first.publish();
expect(await fsp.readdir(root)).toEqual(["same.bin"]);
// The destination is free again once the first one finished.
const again = await openDownload({
rootPath: root,
destPath: path.join(root, "same2.bin"),
transferId: "third",
});
await again.abort();
});
it("allows two downloads of different files side by side and aborts one cleanly", async () => {
const a = await openDownload({
rootPath: root,
destPath: path.join(root, "a.bin"),
transferId: "a",
});
const b = await openDownload({
rootPath: root,
destPath: path.join(root, "b.bin"),
transferId: "b",
});
a.stream.write("A");
b.stream.write("B");
await a.abort();
await b.publish();
expect(await fsp.readdir(root)).toEqual(["b.bin"]);
// Abort after publish is harmless.
await b.abort();
expect(await fsp.readdir(root)).toEqual(["b.bin"]);
});
});
describe("replace primitive (Windows-safe overwrite)", () => {
const seed = async (name: string, existing: string, fresh: Buffer) => {
const dest = path.join(root, name);
const partial = `${dest}.t1.termix-part`;
await fsp.writeFile(dest, existing);
await fsp.writeFile(partial, fresh);
return { dest, partial };
};
it("replaces an existing file without renaming onto an occupied name", async () => {
const fresh = crypto.randomBytes(4096);
const { dest, partial } = await seed("swap.bin", "original", fresh);
const io = windowsLikeFs();
await publishDownload(partial, dest, true, "t1", io);
expect((await fsp.readFile(dest)).equals(fresh)).toBe(true);
expect(await fsp.readdir(root)).toEqual(["swap.bin"]);
expect(io.renames.length).toBeGreaterThan(0);
for (const [, to] of io.renames) {
expect(to === dest || to.endsWith(".termix-replaced")).toBe(true);
}
});
it("restores the original byte for byte when publishing fails", async () => {
const fresh = crypto.randomBytes(1024);
const { dest, partial } = await seed("restore.bin", "original", fresh);
const denied = () =>
Promise.reject(
Object.assign(new Error("EACCES: permission denied"), {
code: "EACCES",
}),
);
const io = windowsLikeFs({ link: denied, copyFileExcl: denied });
await expect(
publishDownload(partial, dest, true, "t1", io),
).rejects.toMatchObject({ code: "EACCES" });
expect(await fsp.readFile(dest, "utf8")).toBe("original");
expect((await fsp.readdir(root)).sort()).toEqual(
["restore.bin", "restore.bin.t1.termix-part"].sort(),
);
});
it("reports EBUSY and touches nothing when the file cannot be moved aside", async () => {
const fresh = crypto.randomBytes(1024);
const { dest, partial } = await seed("locked.bin", "original", fresh);
const io = windowsLikeFs({
rename: () =>
Promise.reject(
Object.assign(new Error("EPERM: operation not permitted"), {
code: "EPERM",
}),
),
});
await expect(
publishDownload(partial, dest, true, "t1", io),
).rejects.toMatchObject({ code: "EBUSY" });
expect(await fsp.readFile(dest, "utf8")).toBe("original");
expect((await fsp.readFile(partial)).equals(fresh)).toBe(true);
});
it("refuses to replace a folder with a file", async () => {
const dest = path.join(root, "folder");
await fsp.mkdir(dest);
await fsp.writeFile(path.join(dest, "inner.txt"), "keep");
const partial = `${dest}.t1.termix-part`;
await fsp.writeFile(partial, "new");
await expect(
publishDownload(partial, dest, true, "t1", windowsLikeFs()),
).rejects.toMatchObject({ code: "EISDIR" });
expect(await fsp.readFile(path.join(dest, "inner.txt"), "utf8")).toBe(
"keep",
);
});
it("falls back to an exclusive publish when the file to replace is gone", async () => {
const dest = path.join(root, "gone.bin");
const partial = `${dest}.t1.termix-part`;
await fsp.writeFile(partial, "new");
await publishDownload(partial, dest, true, "t1", windowsLikeFs());
expect(await fsp.readFile(dest, "utf8")).toBe("new");
expect(await fsp.readdir(root)).toEqual(["gone.bin"]);
});
});
describe("local rename", () => {
it("preserves both files on a name collision", async () => {
const a = path.join(root, "a");
const b = path.join(root, "b");
await fsp.writeFile(a, "source");
await fsp.writeFile(b, "destination");
await expect(renameLocalPath(a, b)).rejects.toThrow(/already exists/);
expect(await fsp.readFile(a, "utf8")).toBe("source");
expect(await fsp.readFile(b, "utf8")).toBe("destination");
});
it("refuses to replace even an empty destination directory", async () => {
const a = path.join(root, "a");
const b = path.join(root, "b");
await fsp.mkdir(a);
await fsp.mkdir(b);
await expect(renameLocalPath(a, b)).rejects.toThrow(/already exists/);
expect((await fsp.readdir(root)).sort()).toEqual(["a", "b"]);
});
it("renames a file and handles an unchanged name", async () => {
const a = path.join(root, "a");
const b = path.join(root, "b");
await fsp.writeFile(a, "source");
await renameLocalPath(a, a);
await renameLocalPath(a, b);
expect(fs.existsSync(a)).toBe(false);
expect(await fsp.readFile(b, "utf8")).toBe("source");
});
it("renameEntry only takes a plain name in the same folder", async () => {
const a = path.join(root, "a");
await fsp.writeFile(a, "x");
await expect(renameEntry(a, "../escape")).rejects.toThrow(/Invalid/);
await expect(renameEntry(a, "")).rejects.toThrow(/Invalid/);
expect(await renameEntry(a, "c")).toBe(path.join(root, "c"));
});
});
describe("listing and creating", () => {
it("lists entries with type, size and hidden flag", async () => {
await fsp.mkdir(path.join(root, "dir"));
await fsp.writeFile(path.join(root, "f.txt"), "abc");
await fsp.writeFile(path.join(root, ".hidden"), "");
const listing = await listDirectory(root);
const byName = Object.fromEntries(listing.entries.map((e) => [e.name, e]));
expect(byName["dir"].type).toBe("directory");
expect(byName["f.txt"]).toMatchObject({ type: "file", size: 3 });
expect(byName[".hidden"].hidden).toBe(true);
expect(listing.parent).toBe(path.dirname(root));
});
it("rejects relative paths", async () => {
await expect(listDirectory("relative")).rejects.toThrow(/absolute/);
});
it("creates folders and files without replacing anything", async () => {
expect(await makeDirectory(root, "new")).toBe(path.join(root, "new"));
await expect(makeDirectory(root, "new")).rejects.toBeTruthy();
expect(await createEmptyFile(root, "a.txt")).toBe(path.join(root, "a.txt"));
await fsp.writeFile(path.join(root, "a.txt"), "keep");
await expect(createEmptyFile(root, "a.txt")).rejects.toBeTruthy();
expect(await fsp.readFile(path.join(root, "a.txt"), "utf8")).toBe("keep");
});
it("reports which paths exist", async () => {
await fsp.writeFile(path.join(root, "yes"), "");
expect(
await existingPaths([path.join(root, "yes"), path.join(root, "no")]),
).toEqual([path.join(root, "yes")]);
});
it("walks dropped paths into a flat list with empty folders", async () => {
await fsp.mkdir(path.join(root, "tree", "empty"), { recursive: true });
await fsp.mkdir(path.join(root, "tree", "sub"));
await fsp.writeFile(path.join(root, "tree", "sub", "a.txt"), "12345");
await fsp.writeFile(path.join(root, "single.txt"), "xy");
const result = await walkPaths([
path.join(root, "tree"),
path.join(root, "single.txt"),
]);
expect(result.files.map((f) => f.relativePath).sort()).toEqual([
"single.txt",
"tree/sub/a.txt",
]);
expect(result.emptyDirs).toEqual(["tree/empty"]);
expect(result.totalBytes).toBe(7);
});
});
@@ -1,23 +1,9 @@
import { createRequire } from "node:module";
import path from "node:path";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { resolveLocalShell, repairSpawnHelpers } =
require("../../../../electron/local-shell.cjs") as {
resolveLocalShell: (
platform: NodeJS.Platform,
requestedShell?: string,
env?: NodeJS.ProcessEnv,
exists?: (file: string) => boolean,
) => { file: string; args: string[] };
repairSpawnHelpers: (
ptyDir: string,
fsImpl: {
statSync: (file: string) => { mode: number };
chmodSync: (file: string, mode: number) => void;
},
) => number;
};
import {
repairSpawnHelpers,
resolveLocalShell,
} from "../../plugins/desktop-local/local-pty.js";
describe("resolveLocalShell", () => {
it("starts the default WSL distribution without PowerShell arguments", () => {
@@ -74,9 +60,9 @@ describe("repairSpawnHelpers", () => {
"/Applications/Termix.app/Contents/Resources/app.asar/node_modules/node-pty";
const unpacked = dir.replace("app.asar", "app.asar.unpacked");
const release = `${unpacked}/build/Release/spawn-helper`;
modes.set(release.split("/").join(require("node:path").sep), 0o644);
modes.set(release.split("/").join(path.sep), 0o644);
modes.set(release, 0o644);
expect(repairSpawnHelpers(dir, fsImpl)).toBe(1);
expect(repairSpawnHelpers(dir, fsImpl as never)).toBe(1);
expect(chmods[0][1] & 0o111).not.toBe(0);
expect(chmods[0][0]).toContain("app.asar.unpacked");
});
@@ -88,11 +74,11 @@ describe("repairSpawnHelpers", () => {
const fsImpl = {
statSync: () => ({ mode: 0o644 }),
chmodSync: (file: string) => {
chmods.push(file.split(require("node:path").sep).join("/"));
chmods.push(file.split(path.sep).join("/"));
},
};
const dir = `/Applications/Termix.app/Contents/Resources/${asar}/node_modules/node-pty`;
repairSpawnHelpers(dir, fsImpl);
repairSpawnHelpers(dir, fsImpl as never);
expect(chmods[0]).toContain(`/${asar}.unpacked/node_modules/node-pty/`);
},
);
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
hostStatus,
provideHostStatus,
registerBuiltinHostStatusProvider,
type HostStatusProvider,
} from "../../plugins/host-status-provider.js";
function fakeProvider(label: string): HostStatusProvider & {
ports: string[];
} {
const ports: string[] = [];
return {
ports,
start: vi.fn(),
stop: vi.fn(),
get: (hostId) => ({ status: "online", lastChecked: `${label}:${hostId}` }),
statusesFor: async () => new Map(),
refresh: vi.fn(async () => {}),
check: async (hostId) => ({
status: "offline",
lastChecked: `${label}:${hostId}`,
}),
reportLogin: vi.fn(),
registerPort: (type) => {
ports.push(`+${type}`);
return () => ports.push(`-${type}`);
},
getIntervalSeconds: async () => 45,
setIntervalSeconds: vi.fn(async () => {}),
};
}
beforeEach(() => {
hostStatus.stop();
registerBuiltinHostStatusProvider(null);
});
describe("host status provider", () => {
it("answers unknown when nothing provides status", async () => {
expect(hostStatus.get(1)).toBeNull();
expect((await hostStatus.statusesFor("u", null)).size).toBe(0);
expect(await hostStatus.check(1)).toBeNull();
expect(await hostStatus.getIntervalSeconds()).toBe(30);
});
it("uses the built-in until a plugin provides", () => {
const builtin = fakeProvider("builtin");
registerBuiltinHostStatusProvider(builtin);
expect(hostStatus.get(1)?.lastChecked).toBe("builtin:1");
const plugin = fakeProvider("plugin");
const remove = provideHostStatus("host-status", plugin);
expect(hostStatus.get(1)?.lastChecked).toBe("plugin:1");
remove();
expect(hostStatus.get(1)?.lastChecked).toBe("builtin:1");
});
it("refuses a second provider", () => {
const remove = provideHostStatus("one", fakeProvider("one"));
expect(() => provideHostStatus("two", fakeProvider("two"))).toThrow(
/already provided by plugin "one"/,
);
remove();
});
it("hands lifecycle over when the provider changes", () => {
const builtin = fakeProvider("builtin");
registerBuiltinHostStatusProvider(builtin);
hostStatus.start();
expect(builtin.start).toHaveBeenCalledTimes(1);
const plugin = fakeProvider("plugin");
const remove = provideHostStatus("host-status", plugin);
expect(builtin.stop).toHaveBeenCalledTimes(1);
expect(plugin.start).toHaveBeenCalledTimes(1);
remove();
expect(plugin.stop).toHaveBeenCalledTimes(1);
expect(builtin.start).toHaveBeenCalledTimes(2);
});
it("keeps registered ports across a provider change", () => {
const builtin = fakeProvider("builtin");
registerBuiltinHostStatusProvider(builtin);
const drop = hostStatus.registerPort("rdp", () => 3389);
expect(builtin.ports).toEqual(["+rdp"]);
const plugin = fakeProvider("plugin");
const remove = provideHostStatus("host-status", plugin);
expect(builtin.ports).toEqual(["+rdp", "-rdp"]);
expect(plugin.ports).toEqual(["+rdp"]);
drop();
expect(plugin.ports).toEqual(["+rdp", "-rdp"]);
remove();
});
});
+79
View File
@@ -132,6 +132,85 @@ describe("PluginLoader discovery", () => {
});
});
describe("PluginLoader themes and skipped looks", () => {
const themed = {
capabilities: ["ui:theme", "ui:layout"],
manifestOverrides: {
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "themes/paper.css",
},
],
layouts: [{ id: "fancy", label: "Fancy" }],
},
},
};
it("loads a plugin whose theme CSS is allowed", async () => {
const fixture = createFixturePlugin(themed);
cleanups.push(fixture.cleanup);
fs.mkdirSync(path.join(fixture.dir, "themes"));
fs.writeFileSync(
path.join(fixture.dir, "themes", "paper.css"),
".paper { --background: #fff; }",
);
const plugin = await new PluginLoader().load(fixture.dir, "bundled");
expect(plugin.manifest.contributes?.themes?.[0].id).toBe("paper");
});
it("refuses a plugin whose theme CSS reaches outside", async () => {
const fixture = createFixturePlugin(themed);
cleanups.push(fixture.cleanup);
fs.mkdirSync(path.join(fixture.dir, "themes"));
fs.writeFileSync(
path.join(fixture.dir, "themes", "paper.css"),
'@import url("https://example.com/x.css");',
);
await expect(
new PluginLoader().load(fixture.dir, "bundled"),
).rejects.toThrow(/theme paper is not allowed: @import/);
});
it("refuses a plugin whose theme file is missing", async () => {
const fixture = createFixturePlugin(themed);
cleanups.push(fixture.cleanup);
await expect(
new PluginLoader().load(fixture.dir, "bundled"),
).rejects.toThrow(/theme paper file themes\/paper.css is missing/);
});
it("remembers why a layout plugin was skipped", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
...themed,
id: "fancy-layout",
root: bundled,
manifestOverrides: {
...themed.manifestOverrides,
version: "2.0.0",
engine: { termix: ">=99.0.0", api: "1" },
},
});
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
const [skipped] = loader.skipped();
expect(skipped).toMatchObject({
id: "fancy-layout",
version: "2.0.0",
layouts: ["fancy"],
themes: ["paper"],
});
expect(skipped.error).toMatch(/needs Termix >=99.0.0/);
});
});
describe("PluginLoader dependency resolution", () => {
async function loadAll(
specs: Array<{ id: string; manifestOverrides?: Record<string, unknown> }>,
+121
View File
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const index = vi.hoisted(() => ({ ids: [] as string[] }));
vi.mock("../../plugins/bundled-index.js", () => ({
defaultLookPluginIds: () => index.ids,
}));
import {
installedLayoutIds,
resolveDefaultLook,
safeLayoutForced,
unavailableLooks,
} from "../../plugins/looks.js";
import type { SkippedPlugin } from "../../plugins/loader.js";
type Contributes = Record<string, unknown>;
function loaderWith(
plugins: Record<string, Contributes>,
skipped: SkippedPlugin[] = [],
) {
return {
get: (id: string) =>
plugins[id]
? ({ manifest: { contributes: plugins[id] } } as never)
: undefined,
list: () =>
Object.values(plugins).map(
(contributes) => ({ manifest: { contributes } }) as never,
),
skipped: () => skipped,
};
}
beforeEach(() => {
index.ids = [];
});
afterEach(() => {
delete process.env.TERMIX_SAFE_LAYOUT;
});
describe("resolveDefaultLook", () => {
it("is empty without a default-look plugin", () => {
expect(resolveDefaultLook(loaderWith({}))).toEqual({
layout: null,
theme: null,
});
});
it("takes the layout marked default and its theme", () => {
index.ids = ["look-a"];
const loader = loaderWith({
"look-a": {
layouts: [
{ id: "first", label: "First", defaultTheme: "one" },
{ id: "main", label: "Main", defaultTheme: "two", default: true },
],
},
});
expect(resolveDefaultLook(loader)).toEqual({
layout: "main",
theme: "two",
});
});
it("takes the first layout, and a theme from a themes-only plugin", () => {
index.ids = ["look-a", "look-b"];
const loader = loaderWith({
"look-a": { themes: [{ id: "paper", label: "Paper" }] },
"look-b": { layouts: [{ id: "solo", label: "Solo" }] },
});
expect(resolveDefaultLook(loader)).toEqual({
layout: "solo",
theme: "paper",
});
});
it("skips a default-look plugin that did not load", () => {
index.ids = ["gone"];
expect(resolveDefaultLook(loaderWith({}))).toEqual({
layout: null,
theme: null,
});
});
});
describe("unavailableLooks", () => {
it("lists only skipped plugins that brought a layout or theme", () => {
const skipped: SkippedPlugin[] = [
{ id: "a", version: "1.0.0", layouts: ["x"], themes: [], error: "e" },
{ id: "b", version: null, layouts: [], themes: [], error: "e" },
{ id: "c", version: "2.0.0", layouts: [], themes: ["t"], error: "e" },
];
expect(
unavailableLooks(loaderWith({}, skipped)).map((plugin) => plugin.id),
).toEqual(["a", "c"]);
});
});
describe("safeLayoutForced", () => {
it("reads TERMIX_SAFE_LAYOUT", () => {
expect(safeLayoutForced()).toBe(false);
process.env.TERMIX_SAFE_LAYOUT = "1";
expect(safeLayoutForced()).toBe(true);
process.env.TERMIX_SAFE_LAYOUT = "true";
expect(safeLayoutForced()).toBe(true);
process.env.TERMIX_SAFE_LAYOUT = "0";
expect(safeLayoutForced()).toBe(false);
});
});
describe("installedLayoutIds", () => {
it("lists layouts of loaded and skipped plugins", () => {
const ids = installedLayoutIds(
loaderWith({ a: { layouts: [{ id: "one" }], themes: [{ id: "t" }] } }, [
{ id: "b", version: null, layouts: ["two"], themes: [], error: "x" },
]),
);
expect([...ids].sort()).toEqual(["one", "two"]);
});
});
+74
View File
@@ -289,6 +289,32 @@ describe("uninstall and reinstall of a bundled plugin", () => {
expect(loader.get("docker")?.source).toBe("user");
});
it("refuses to uninstall the default look but lets it be turned off", async () => {
createFixturePlugin({ id: "some-look", root: bundled });
fs.writeFileSync(
path.join(bundled, "bundled-index.json"),
JSON.stringify({
version: 1,
plugins: { "some-look": { defaultLook: true } },
}),
);
const { resetBundledIndexCache } =
await import("../../plugins/bundled-index.js");
resetBundledIndexCache();
try {
const loader = await boot();
await expect(manage.uninstallPlugin("some-look")).rejects.toMatchObject({
code: "DEFAULT_LOOK",
status: 409,
});
expect(loader.get("some-look")?.state).toBe("active");
expect(fs.existsSync(path.join(bundled, "some-look"))).toBe(true);
} finally {
fs.rmSync(path.join(bundled, "bundled-index.json"), { force: true });
resetBundledIndexCache();
}
});
it("deletes a plugin dropped in as a plain folder", async () => {
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
const loader = await boot();
@@ -635,6 +661,54 @@ describe("reloading a bundled plugin for the dev runner", () => {
});
});
describe("restoring the bundled copy", () => {
it("drops a downloaded update, loads the shipped copy and pins it", async () => {
createFixturePlugin({ id: "docker", root: bundled });
const loader = await boot();
// What a signed update leaves behind next to the shipped copy.
const artifact = path.join(data, "plugins", "docker.tmxplug");
fs.mkdirSync(path.dirname(artifact), { recursive: true });
fs.writeFileSync(artifact, "update");
fs.writeFileSync(`${artifact}.sig`, "sig");
const result = await manage.restoreBundledPlugin("docker");
expect(result).toEqual({ id: "docker", version: "1.0.0", state: "active" });
expect(loader.get("docker")?.manifest.version).toBe("1.0.0");
expect(fs.existsSync(artifact)).toBe(false);
expect(fs.existsSync(`${artifact}.sig`)).toBe(false);
expect(db.rows.get("docker")).toMatchObject({
pinnedVersion: "1.0.0",
autoUpdate: false,
});
});
it("keeps a plugin that is turned off stopped", async () => {
createFixturePlugin({ id: "docker", root: bundled });
const loader = await boot();
await manage.setPluginState("docker", false);
const result = await manage.restoreBundledPlugin("docker");
expect(result.state).toBe("stopped");
expect(loader.get("docker")?.state).toBe("loaded");
});
it("refuses a plugin that has no bundled copy", async () => {
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
await boot();
await expect(manage.restoreBundledPlugin("notes")).rejects.toMatchObject({
code: "NO_BUNDLED_COPY",
status: 409,
});
});
it("refuses a bad id", async () => {
await expect(manage.restoreBundledPlugin("../x")).rejects.toMatchObject({
code: "INVALID_ID",
status: 400,
});
});
});
describe("consent", () => {
it("refuses an install without the consented list, before any download", async () => {
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
@@ -1002,3 +1002,101 @@ describe("docs and env fields", () => {
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
});
});
describe("layouts and themes", () => {
const layouts = [
{
id: "fancy",
label: "layout.fancy",
defaultTheme: "paper",
default: true,
},
];
const themes = [
{
id: "paper",
label: "theme.paper",
mode: "light",
css: "themes/paper.css",
swatch: { background: "#ffffff", panel: "#eeeeee", text: "#111111" },
},
];
it("accepts layouts and themes with their capabilities", () => {
const { manifest, errors } = parseManifest(
base({
capabilities: ["ui:layout", "ui:theme"],
contributes: { layouts, themes },
}),
);
expect(errors).toEqual([]);
expect(manifest?.contributes?.layouts?.[0].id).toBe("fancy");
expect(manifest?.contributes?.themes?.[0].mode).toBe("light");
});
it("needs ui:layout for layouts and ui:theme for themes", () => {
const { errors } = parseManifest(
base({ capabilities: ["kv:own"], contributes: { layouts, themes } }),
);
expect(errors.some((e) => e.includes('"ui:layout"'))).toBe(true);
expect(errors.some((e) => e.includes('"ui:theme"'))).toBe(true);
});
it("refuses bad ids, modes, paths and swatches", () => {
const errors = validateManifest(
base({
capabilities: ["ui:layout", "ui:theme"],
contributes: {
layouts: [
{ id: "Fancy", label: "x" },
{ id: "two", label: "x", default: true },
{ id: "three", label: "x", default: true, extra: 1 },
],
themes: [
{ id: "paper", label: "x", mode: "sepia", css: "../paper.css" },
{ id: "ink", label: "x", mode: "dark", css: "ink.scss" },
{
id: "dusk",
label: "x",
mode: "dark",
css: "dusk.css",
swatch: { background: "red", panel: "#000", text: "#fff" },
},
{ id: "dusk", label: "x", mode: "dark", css: "dusk2.css" },
],
},
}),
);
const has = (text: string) => errors.some((e) => e.includes(text));
expect(has("contributes.layouts[0].id must match")).toBe(true);
expect(has("Only one entry")).toBe(true);
expect(has('Unknown field "extra"')).toBe(true);
expect(has('mode must be "light" or "dark"')).toBe(true);
expect(has("relative path inside the plugin")).toBe(true);
expect(has("must be a .css file")).toBe(true);
expect(has("swatch.background must be a hex color")).toBe(true);
expect(has('duplicates "dusk"')).toBe(true);
});
});
describe("manifest runtime", () => {
it("accepts a desktop runtime plugin", () => {
expect(
validateManifest(
base({ runtime: "desktop", capabilities: ["desktop:local"] }),
),
).toEqual([]);
});
it("rejects an unknown runtime", () => {
expect(validateManifest(base({ runtime: "cloud" })).join()).toMatch(
/runtime/,
);
});
it("rejects a desktop runtime that is mirrored or server only", () => {
expect(
validateManifest(base({ runtime: "desktop", desktop: "mirror" })).join(),
).toMatch(/local/);
});
});
@@ -200,6 +200,10 @@ describe("assertOwnedTables", () => {
const owners = Object.entries(LEGACY_TABLE_OWNERS);
expect(new Set(owners.map(([table]) => table)).size).toBe(owners.length);
});
it("lets the homepage plugin adopt recent_activity", () => {
expect(LEGACY_TABLE_OWNERS.recent_activity).toBe("homepage");
});
});
describe("applyPluginMigrations", () => {
@@ -1,6 +1,6 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { JumpProbePool } from "../../../hosts/status/jump-probe-pool.js";
import { JumpProbePool } from "../../../plugins/probe/jump-probe-pool.js";
function fakeClient(reachable = true) {
const client = Object.assign(new EventEmitter(), {
@@ -1,6 +1,6 @@
import type { Client } from "ssh2";
import { describe, expect, it, vi } from "vitest";
import { tcpPingThroughJumpHost } from "../../../hosts/status/tcp-ping.js";
import { tcpPingThroughJumpHost } from "../../../plugins/probe/tcp-ping.js";
describe("tcpPingThroughJumpHost", () => {
it("reports the final destination online when forwarding succeeds", async () => {
@@ -12,6 +12,10 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: fetchMock,
}));
vi.mock("../../utils/app-version.js", () => ({
getLocalVersion: () => "26.10.3",
}));
vi.mock("../../plugins/trust.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../plugins/trust.js")>();
@@ -44,7 +48,9 @@ import {
findRelease,
latestOnChannel,
getStatsUrl,
canRunRelease,
isApiCompatible,
neededTermix,
parseRegistryIndex,
parseRegistryStats,
resetRegistryCache,
@@ -239,6 +245,56 @@ describe("findRelease", () => {
});
});
describe("engine ranges", () => {
const engine = (termix: string, api = "^1.0") => ({ api, termix });
it("reads gated releases into versions and prereleases", () => {
const index = parseRegistryIndex({
plugins: [
{
id: "classic-layout",
versions: [],
gated: [
version({ version: "1.1.0-beta.1", engine: engine(">=26.10.3") }),
version({ version: "1.0.0", engine: engine(">=26.10.3") }),
],
contributes: ["layouts", "tabs", "not valid"],
},
],
});
const [plugin] = index.plugins;
expect(plugin.versions.map((v) => v.version)).toEqual(["1.0.0"]);
expect(plugin.prereleases.map((v) => v.version)).toEqual(["1.1.0-beta.1"]);
expect(plugin.contributes).toEqual(["layouts", "tabs"]);
});
it("checks engine.termix and the full engine.api", () => {
expect(canRunRelease(version({ engine: engine(">=26.10.3") }))).toBe(true);
expect(canRunRelease(version({ engine: engine(">=26.11.0") }))).toBe(false);
expect(neededTermix(version({ engine: engine(">=26.11.0") }))).toBe(
">=26.11.0",
);
expect(neededTermix(version({ engine: engine(">=26.10.0") }))).toBeNull();
expect(
canRunRelease(version({ engine: engine(">=26.10.0", "^1.99") })),
).toBe(false);
expect(canRunRelease(version())).toBe(true);
});
it("skips a release this build is too old for", () => {
const index = parseRegistryIndex({
plugins: [
{
id: "docker",
versions: [version({ version: "1.0.0" })],
gated: [version({ version: "1.1.0", engine: engine(">=26.11.0") })],
},
],
});
expect(findRelease(index, "docker")?.release.version).toBe("1.0.0");
});
});
describe("prereleases", () => {
const index = parseRegistryIndex({
plugins: [
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
detectSshKeyType,
generateSshKeyPair,
publicKeyFromPrivate,
validateSshKeyPair,
} from "../../../../packages/plugin-sdk/src/ssh-keys.js";
describe("plugin SDK ssh-keys", () => {
it("generates a usable ed25519 pair", () => {
const pair = generateSshKeyPair("ssh-ed25519", { comment: "test" });
expect(pair.publicKey).toMatch(/^ssh-ed25519 /);
expect(detectSshKeyType(pair.privateKey)).toBe("ssh-ed25519");
expect(detectSshKeyType(pair.publicKey)).toBe("ssh-ed25519");
expect(validateSshKeyPair(pair.privateKey, pair.publicKey)).toEqual({
isValid: true,
});
});
it("derives the public key and ignores the comment when matching", () => {
const pair = generateSshKeyPair("ecdsa-sha2-nistp256");
const derived = publicKeyFromPrivate(pair.privateKey);
expect(derived).toMatch(/^ecdsa-sha2-nistp256 /);
expect(
validateSshKeyPair(pair.privateKey, `${derived} someone@host`).isValid,
).toBe(true);
});
it("generates RSA with a minimum size", () => {
expect(() => generateSshKeyPair("ssh-rsa", { bits: 512 })).toThrow(/2048/);
const pair = generateSshKeyPair("ssh-rsa", { bits: 2048 });
expect(detectSshKeyType(pair.privateKey)).toBe("ssh-rsa");
});
it("works with a passphrase and fails without it", () => {
const pair = generateSshKeyPair("ssh-ed25519", { passphrase: "secret" });
expect(publicKeyFromPrivate(pair.privateKey)).toBeNull();
expect(publicKeyFromPrivate(pair.privateKey, "secret")).toBe(
pair.publicKey.split(" ").slice(0, 2).join(" "),
);
});
it("flags a pair that does not belong together", () => {
const a = generateSshKeyPair("ssh-ed25519");
const b = generateSshKeyPair("ssh-ed25519");
const result = validateSshKeyPair(a.privateKey, b.publicKey);
expect(result.isValid).toBe(false);
expect(result.error).toMatch(/match/);
});
it("reports garbage as unreadable", () => {
expect(detectSshKeyType("not a key")).toBeNull();
expect(publicKeyFromPrivate("not a key")).toBeNull();
expect(validateSshKeyPair("x", "y").isValid).toBe(false);
});
it("rejects an unknown key type", () => {
expect(() => generateSshKeyPair("ssh-dss" as never)).toThrow(/Unsupported/);
});
});
@@ -71,6 +71,7 @@ const UNGATED: Record<string, string> = {
"rbac.hasFor": "a yes/no about a role permission, grants nothing",
"rbac.require": "route middleware for the plugin's own permissions",
"capabilities.has": "a yes/no about the plugin's own grants",
"auth.listSecondFactors": "ids of the second factors, no user data",
"disposables.add": "the plugin's own cleanup",
"schedule.every": "the plugin's own timers",
"schedule.after": "the plugin's own timers",
@@ -79,6 +80,7 @@ const UNGATED: Record<string, string> = {
"audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)",
currentActor: "reads the actor, cannot set it",
"desktop.available": "a yes/no about the environment",
"desktop.local.available": "a yes/no about the environment",
"http.baseUrl": "reads the request's public origin",
"ssh.poolKey": "a string built from a host the plugin already holds",
"ssh.dropPooled": "closes only this plugin's own pooled connections",
@@ -162,7 +164,15 @@ describe("every privileged ctx member is guarded", () => {
let caught: unknown;
await runAsActor(ACTOR, "request", async () => {
try {
await member.fn(...PLACEHOLDER_ARGS);
const result = await member.fn(...PLACEHOLDER_ARGS);
// An async generator only runs its guard on the first next().
if (
result &&
typeof (result as AsyncGenerator).next === "function" &&
Symbol.asyncIterator in (result as object)
) {
await (result as AsyncGenerator).next();
}
} catch (error) {
caught = error;
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { validateThemeCss } from "../../plugins/manifest.js";
describe("validateThemeCss", () => {
it("accepts token rules scoped under the theme class", () => {
const css = `
/* Paper */
.paper { --background: #fff; --foreground: #111; }
:root.paper, html.paper .card { --card: #f8f8f8; }
@media (prefers-reduced-motion: reduce) {
.paper * { transition: none; }
}
@supports (color: oklch(0 0 0)) { .paper { --border: oklch(0.9 0 0); } }
.paper .logo { background: url("logo.svg") no-repeat; }
.paper .dot { background: url(data:image/png;base64,AAAA); }
`;
expect(validateThemeCss(css, "paper")).toEqual([]);
});
it("refuses selectors outside the theme", () => {
const errors = validateThemeCss(
".paper { --a: 1 } body { color: red } .paperish { --b: 2 } .dark { --c: 3 }",
"paper",
);
expect(errors).toHaveLength(3);
expect(errors[0]).toContain('"body"');
});
it("refuses anything that loads or runs", () => {
const errors = validateThemeCss(
`@import url("https://evil.example/x.css");
.paper { background: url(https://evil.example/a.png); }
.paper { background: url(//evil.example/a.png); }
.paper { width: expression(alert(1)); }
.paper { background: url(javascript:alert(1)); }
.paper { behavior: url(x.htc); }
@font-face { font-family: x; src: url(x.woff2); }
@keyframes spin { from { opacity: 0 } }`,
"paper",
);
const has = (text: string) => errors.some((e) => e.includes(text));
expect(has("@import is not allowed")).toBe(true);
expect(has("url(https://evil.example/a.png) points outside")).toBe(true);
expect(has("url(//evil.example/a.png) points outside")).toBe(true);
expect(has("expression() is not allowed")).toBe(true);
expect(has("javascript: is not allowed")).toBe(true);
expect(has("Script bindings")).toBe(true);
expect(has("@font-face is not allowed")).toBe(true);
expect(has("@keyframes is not allowed")).toBe(true);
});
it("refuses nested rules and broken braces", () => {
expect(validateThemeCss(".paper { .x { color: red } }", "paper")).toEqual([
'Nested rules are not allowed in ".paper"',
]);
expect(validateThemeCss(".paper { color: red", "paper")).toEqual([
"A rule is missing its closing brace",
]);
});
it("does not trip on braces inside strings", () => {
expect(
validateThemeCss('.paper .x::after { content: "{ }"; }', "paper"),
).toEqual([]);
});
});
@@ -155,6 +155,30 @@ describe("plugin user data", () => {
});
});
it("exports a table with a text primary key", async () => {
const credentials = defineTable("credential", {
id: varchar(64).primaryKey(),
userId: refUser().notNull(),
label: text(),
});
for (const statement of createTableSql("sqlite", "fixture", credentials)) {
sqlite.exec(statement);
}
registerTable("fixture", credentials);
sqlite.exec(
"INSERT INTO p_fixture_credential (id, user_id, label) VALUES ('abc', 'u1', 'key')",
);
const file = new Database(":memory:");
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
file.exec("INSERT INTO users (id) VALUES ('u1')");
await writeUserPluginTables(file, "u1");
expect(file.prepare("SELECT * FROM p_fixture_credential").all()).toEqual([
{ id: "abc", user_id: "u1", label: "key" },
]);
});
it("writes large tables in chunks and lets the event loop run between them", async () => {
const insert = sqlite.prepare(
"INSERT INTO p_fixture_bookmark (user_id, path) VALUES ('u1', ?)",
+20
View File
@@ -154,6 +154,26 @@ describe("ctx.ws routing", () => {
if (result.outcome === "open") result.socket.close();
});
it("serves a socket core owns without any plugin grant", async () => {
state.granted = new Set();
const seen: string[] = [];
ws.registerCoreWsRoute("/relay", ({ userId }) => {
seen.push(userId);
});
const base = await startServer();
const signedIn = await connect(
`${base}/plugin-ws/core/relay`,
authProtocols(state.validToken),
);
const anonymous = await connect(`${base}/plugin-ws/core/relay`);
expect(signedIn.outcome).toBe("open");
expect(anonymous.outcome).toBe(401);
expect(seen).toEqual([state.userId]);
if (signedIn.outcome === "open") signedIn.socket.close();
});
it("404s a path nothing registered", async () => {
const base = await startServer();
+116
View File
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
users: [] as Array<{
id: string;
username: string;
isAdmin: boolean;
registeredAt: string;
}>,
saved: null as string | null,
create: vi.fn(),
assignRole: vi.fn(),
registerUser: vi.fn(),
deleteUserAndRelatedData: vi.fn(),
set: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
listAll: async () => mocks.users,
findById: async (id: string) =>
mocks.users.find((u) => u.id === id) ?? null,
create: mocks.create,
}),
createCurrentRoleRepository: () => ({
assignRoleNameToUser: mocks.assignRole,
}),
createCurrentSettingsRepository: () => ({
get: async () => mocks.saved,
set: async (key: string, value: string) => {
mocks.set(key, value);
mocks.saved = value;
},
}),
}));
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({ registerUser: mocks.registerUser }) },
}));
vi.mock("../../database/routes/delete-user-data.js", () => ({
deleteUserAndRelatedData: mocks.deleteUserAndRelatedData,
}));
const {
createAccount,
getActiveUserId,
listAccounts,
removeAccount,
setActiveUserId,
} = await import("../../sync/accounts.js");
function user(
id: string,
isAdmin = false,
registeredAt = "2026-01-01T00:00:00Z",
) {
return { id, username: id, isAdmin, registeredAt };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.saved = null;
mocks.users = [
user("local", true),
user("work", false, "2026-02-01T00:00:00Z"),
];
});
describe("desktop accounts", () => {
it("signs in as the usual user until one is chosen", async () => {
expect(await getActiveUserId()).toBe("local");
await setActiveUserId("work");
expect(await getActiveUserId()).toBe("work");
});
it("will not switch to an account that does not exist", async () => {
await expect(setActiveUserId("nope")).rejects.toThrow("No such account");
expect(mocks.set).not.toHaveBeenCalled();
});
it("lists the accounts with the active one marked", async () => {
await setActiveUserId("work");
expect(await listAccounts()).toEqual([
expect.objectContaining({ id: "local", active: false }),
expect.objectContaining({ id: "work", active: true }),
]);
});
it("makes an empty admin account with its own data key", async () => {
const id = await createAccount();
expect(mocks.create).toHaveBeenCalledWith(
expect.objectContaining({ id, isAdmin: true, passwordHash: "" }),
);
expect(mocks.assignRole).toHaveBeenCalledWith(
expect.objectContaining({ userId: id, roleName: "admin" }),
);
expect(mocks.registerUser).toHaveBeenCalledWith(id, expect.any(String));
});
it("removes an account and its data", async () => {
await removeAccount("work");
expect(mocks.deleteUserAndRelatedData).toHaveBeenCalledWith("work");
});
it("moves to another account when the active one is removed", async () => {
await setActiveUserId("work");
mocks.set.mockClear();
await removeAccount("work");
expect(mocks.set).toHaveBeenCalledWith("desktop_active_user", "local");
});
it("keeps the last account", async () => {
mocks.users = [user("local", true)];
await expect(removeAccount("local")).rejects.toThrow("last account");
expect(mocks.deleteUserAndRelatedData).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,204 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import https from "node:https";
import os from "node:os";
import path from "node:path";
import type { AddressInfo } from "node:net";
import { sql } from "drizzle-orm";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
const state = vi.hoisted(() => ({ drizzle: null as unknown }));
vi.mock("../../../database/db/index.js", () => ({
getDb: () => state.drizzle,
getSqlite: () => null,
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
},
}));
vi.mock("../../../utils/system-crypto.js", () => ({
SystemCrypto: {
getInstance: () => ({ getEncryptionKey: async () => Buffer.alloc(32, 5) }),
},
}));
const { TestSqliteDatabase } =
await import("../../database/repositories/test-support.js");
const { createLink, getLink, updateLink } =
await import("../../../sync/client/link-store.js");
const { RemoteError, remoteJson } =
await import("../../../sync/client/http.js");
function hasOpenssl(): boolean {
try {
execFileSync("openssl", ["version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
const openssl = hasOpenssl();
const PASSPHRASE = "pfx-pass";
let dir = "";
let pfx = "";
let serverKey = "";
let serverCert = "";
let clientCert = "";
function run(args: string[]) {
execFileSync("openssl", args, { cwd: dir, stdio: "ignore" });
}
function selfSigned(name: string) {
run([
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-days",
"2",
"-keyout",
`${name}.key`,
"-out",
`${name}.crt`,
"-subj",
`/CN=${name}`,
]);
}
beforeAll(() => {
if (!openssl) return;
dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-mtls-"));
selfSigned("server");
selfSigned("client");
run([
"pkcs12",
"-export",
"-inkey",
"client.key",
"-in",
"client.crt",
"-out",
"client.p12",
"-passout",
`pass:${PASSPHRASE}`,
]);
pfx = fs.readFileSync(path.join(dir, "client.p12")).toString("base64");
serverKey = fs.readFileSync(path.join(dir, "server.key"), "utf8");
serverCert = fs.readFileSync(path.join(dir, "server.crt"), "utf8");
clientCert = fs.readFileSync(path.join(dir, "client.crt"), "utf8");
});
afterAll(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
describe.skipIf(!openssl)("client certificates", () => {
let server: https.Server;
let base: string;
beforeAll(async () => {
server = https.createServer(
{
key: serverKey,
cert: serverCert,
ca: [clientCert],
requestCert: true,
rejectUnauthorized: true,
},
(_req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: true }));
},
);
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
base = `https://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
it("presents the PFX to a server that requires one", async () => {
const result = await remoteJson<{ ok: boolean }>(
{
serverUrl: base,
allowInvalidCertificate: true,
clientCert: { pfx, passphrase: PASSPHRASE, name: "client.p12" },
},
"/x",
);
expect(result.ok).toBe(true);
});
it("reports a missing client certificate as its own error", async () => {
const error = await remoteJson(
{ serverUrl: base, allowInvalidCertificate: true },
"/x",
).catch((e) => e);
expect(error).toBeInstanceOf(RemoteError);
expect((error as InstanceType<typeof RemoteError>).kind).toBe(
"client_cert",
);
});
describe("on the link", () => {
let db: InstanceType<typeof TestSqliteDatabase>;
beforeEach(async () => {
db = new TestSqliteDatabase("sqlite");
state.drizzle = (await db.connect()).drizzle;
await db.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('local', 'local', '')`,
);
});
afterEach(async () => {
await db.close();
});
it("stores the PFX encrypted and keeps it through other updates", async () => {
await createLink({
userId: "local",
serverUrl: "https://termix.example",
sessionToken: "t",
clientCert: { pfx, passphrase: PASSPHRASE, name: "client.p12" },
});
const raw = await db.query(sql`SELECT scope FROM sync_link`);
expect(JSON.stringify(raw)).not.toContain(pfx.slice(0, 40));
await updateLink({ disabledTypes: ["hosts"] }, "local");
const link = await getLink("local");
expect(link?.clientCert?.pfx).toBe(pfx);
expect(link?.disabledTypes).toEqual(["hosts"]);
await updateLink({ clientCert: null }, "local");
expect((await getLink("local"))?.clientCert).toBeNull();
});
it("rejects a PFX with the wrong passphrase", async () => {
await expect(
createLink({
userId: "local",
serverUrl: "https://termix.example",
sessionToken: "t",
clientCert: { pfx, passphrase: "nope", name: null },
}),
).rejects.toThrow(/not valid/);
});
});
});
@@ -21,8 +21,9 @@ vi.mock("../../../utils/system-crypto.js", () => ({
const { TestSqliteDatabase } =
await import("../../database/repositories/test-support.js");
const { createLink, deleteLink, getLink, updateLink } =
const { createLink, deleteLink, getLink, listLinks, updateLink } =
await import("../../../sync/client/link-store.js");
const { setActiveUserId } = await import("../../../sync/accounts.js");
let db: InstanceType<typeof TestSqliteDatabase>;
@@ -96,3 +97,79 @@ describe("the desktop's link", () => {
expect(await updateLink({ cursor: 1 })).toBeNull();
});
});
describe("several links", () => {
beforeEach(async () => {
await db.run(sql`UPDATE users SET is_admin = 1 WHERE id = 'local'`);
await db.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('second', 'second', '')`,
);
});
it("keeps one link per local user", async () => {
await createLink({
userId: "local",
serverUrl: "https://a",
sessionToken: "a",
});
await createLink({
userId: "second",
serverUrl: "https://b",
sessionToken: "b",
});
expect((await listLinks()).map((link) => link.serverUrl).sort()).toEqual([
"https://a",
"https://b",
]);
expect((await getLink("second"))?.serverUrl).toBe("https://b");
expect((await getLink("local"))?.serverUrl).toBe("https://a");
});
it("answers for the active account when no user is given", async () => {
await createLink({
userId: "local",
serverUrl: "https://a",
sessionToken: "a",
});
await createLink({
userId: "second",
serverUrl: "https://b",
sessionToken: "b",
});
expect((await getLink())?.serverUrl).toBe("https://a");
await setActiveUserId("second");
expect((await getLink())?.serverUrl).toBe("https://b");
});
it("changes and removes only the link it is asked about", async () => {
await createLink({
userId: "local",
serverUrl: "https://a",
sessionToken: "a",
});
await createLink({
userId: "second",
serverUrl: "https://b",
sessionToken: "b",
});
await updateLink({ cursor: 9, status: "offline" }, "second");
expect((await getLink("local"))?.cursor).toBe(0);
expect((await getLink("second"))?.cursor).toBe(9);
await deleteLink("second");
expect(await getLink("second")).toBeNull();
expect((await getLink("local"))?.serverUrl).toBe("https://a");
});
it("stores connection options without touching the type switches", async () => {
await createLink({
userId: "local",
serverUrl: "https://a",
sessionToken: "a",
});
await updateLink({ disabledTypes: ["hosts"] }, "local");
await updateLink({ options: { clientCertificate: "work" } }, "local");
const link = await getLink("local");
expect(link?.options).toEqual({ clientCertificate: "work" });
expect(link?.disabledTypes).toEqual(["hosts"]);
});
});
@@ -1,26 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
vi.mock("../../database/db/index.js", () => ({}));
import { registerCoreSyncEntities } from "../../sync/entities.js";
import {
listEntityTypes,
resetSyncRegistry,
} from "../../plugins/sync-registry.js";
afterEach(() => resetSyncRegistry());
describe("core sync entities", () => {
it("each has a label in the Sync panel", () => {
registerCoreSyncEntities();
const en = JSON.parse(
fs.readFileSync(
new URL("../../../ui/locales/en.json", import.meta.url),
"utf8",
),
);
const missing = listEntityTypes().filter((type) => !en.sync.entities[type]);
expect(missing).toEqual([]);
});
});
+217
View File
@@ -0,0 +1,217 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listChanges: vi.fn(),
findHostIdBySyncId: vi.fn(),
canAccessHost: vi.fn(),
findById: vi.fn(),
getLink: vi.fn(),
generateJWTToken: vi.fn(),
revokeSession: vi.fn(),
}));
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware: () => vi.fn(),
generateJWTToken: mocks.generateJWTToken,
revokeSession: mocks.revokeSession,
}),
},
}));
vi.mock("../../utils/logger.js", () => ({
syncLogger: { error: vi.fn(), warn: vi.fn() },
}));
vi.mock("../../utils/app-version.js", () => ({
getLocalVersion: () => "26.10.3",
}));
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
vi.mock("../../plugins/sync-registry.js", () => ({
getEntity: vi.fn(),
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
onEntitiesChanged: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostRepository: () => ({ findById: mocks.findById }),
createCurrentHostResolutionRepository: () => ({
findHostIdBySyncId: mocks.findHostIdBySyncId,
}),
createCurrentPluginPermissionGrantRepository: vi.fn(),
createCurrentPluginRepository: vi.fn(),
createCurrentRepositoryContext: vi.fn(),
createCurrentSessionRepository: vi.fn(),
createCurrentSettingsRepository: vi.fn(),
createCurrentUserRepository: vi.fn(),
}));
vi.mock("../../database/routes/branding-settings.js", () => ({
resolveBrandingSettings: async () => ({ appName: "Termix" }),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ canAccessHost: mocks.canAccessHost }),
},
}));
vi.mock("../../sync/entities.js", () => ({
registerCoreSyncEntities: vi.fn(),
}));
vi.mock("../../sync/store.js", () => ({}));
vi.mock("../../sync/wire.js", () => ({}));
vi.mock("../../sync/records.js", () => ({
listChanges: mocks.listChanges,
recordKey: (type: string, id: string) => `${type}\u0000${id}`,
}));
vi.mock("../../sync/server/write-listeners.js", () => ({
onLocalWrite: vi.fn(),
}));
vi.mock("../../sync/accounts.js", () => ({
createAccount: vi.fn(),
getActiveUserId: vi.fn(),
listAccounts: vi.fn(),
removeAccount: vi.fn(),
setActiveUserId: vi.fn(),
}));
vi.mock("../../sync/server/feed.js", () => ({
withUserLock: (_userId: string, fn: () => unknown) => fn(),
reconcileUser: async () => ({ wire: new Map() }),
addListener: vi.fn(),
}));
vi.mock("../../sync/server/push.js", () => ({}));
vi.mock("../../sync/client/link-store.js", () => ({
getLink: mocks.getLink,
listLinks: vi.fn(),
createLink: vi.fn(),
updateLink: vi.fn(),
deleteLink: vi.fn(),
}));
vi.mock("../../sync/client/http.js", () => ({
normalizeServerUrl: vi.fn(),
RemoteError: class RemoteError extends Error {},
remoteFetch: vi.fn(),
remoteJson: vi.fn(),
}));
vi.mock("../../sync/client/electron-proxy.js", () => ({
applyElectronProxyConfig: vi.fn(),
}));
vi.mock("../../sync/client/plugins.js", () => ({
lastRemotePlugins: () => [],
mirrorPlugins: vi.fn(),
}));
const { createSyncHost } = await import("../../sync/host.js");
beforeEach(() => {
vi.clearAllMocks();
mocks.listChanges.mockResolvedValue([]);
});
describe("host.feed.changes", () => {
it("answers an empty page with the entity types past the newest change", async () => {
const cursor = Number.MAX_SAFE_INTEGER;
const page = await createSyncHost().feed.changes("user-1", cursor, 1000);
expect(page).toEqual({
records: [],
hasMore: false,
nextCursor: cursor,
entityTypes: [
{ type: "hosts", owner: "core", order: 10, readOnly: false },
],
});
});
it("asks for one more row than the limit to know if more remain", async () => {
await createSyncHost().feed.changes("user-1", 0, 1000);
expect(mocks.listChanges).toHaveBeenCalledWith(
"user-1",
0,
1001,
undefined,
);
});
it("leaves out records of types this server does not run", async () => {
mocks.listChanges.mockResolvedValue([
{ seq: 5, entityType: "gone", syncId: "a", revision: 1, deleted: false },
]);
const page = await createSyncHost().feed.changes("user-1", 0, 10);
expect(page.records).toEqual([]);
expect(page.nextCursor).toBe(5);
});
});
describe("host.hosts.findForSync", () => {
beforeEach(() => {
mocks.findHostIdBySyncId.mockResolvedValue(41);
mocks.canAccessHost.mockImplementation(async (_user, _host, action) => ({
hasAccess: action === "connect",
}));
mocks.findById.mockResolvedValue({ name: "Shared host", ip: "10.0.0.1" });
});
it("resolves the server ID for a connect only recipient", async () => {
const found = await createSyncHost().hosts.findForSync(
"recipient",
"host-sync-id",
);
expect(mocks.canAccessHost).toHaveBeenCalledWith(
"recipient",
41,
"connect",
);
expect(found).toEqual({ id: 41, name: "Shared host" });
});
it("does not disclose a host the caller cannot connect to", async () => {
mocks.canAccessHost.mockResolvedValue({ hasAccess: false });
expect(await createSyncHost().hosts.findForSync("u", "s")).toBeNull();
});
it("returns null for a missing sync ID", async () => {
mocks.findHostIdBySyncId.mockResolvedValue(null);
expect(await createSyncHost().hosts.findForSync("u", "s")).toBeNull();
expect(mocks.canAccessHost).not.toHaveBeenCalled();
});
});
describe("host.sessions", () => {
it("issues a long desktop session that shows in the sessions list", async () => {
mocks.generateJWTToken.mockResolvedValue("token");
const token = await createSyncHost().sessions.issueDesktop("u1", {
days: 365,
deviceInfo: "Laptop (sync)",
});
expect(token).toBe("token");
expect(mocks.generateJWTToken).toHaveBeenCalledWith("u1", {
expiresIn: "365d",
deviceType: "desktop",
deviceInfo: "Laptop (sync)",
});
});
it("answers 404 outside the desktop app", () => {
const previous = process.env.ELECTRON_EMBEDDED;
delete process.env.ELECTRON_EMBEDDED;
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
const next = vi.fn();
(
createSyncHost().sessions.desktopOnly() as (
req: unknown,
res: unknown,
next: () => void,
) => void
)({}, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(next).not.toHaveBeenCalled();
if (previous !== undefined) process.env.ELECTRON_EMBEDDED = previous;
});
});
describe("host.links", () => {
it("reads the linked server registry", async () => {
mocks.getLink.mockResolvedValue({ id: 1, serverUrl: "https://a.example" });
expect(await createSyncHost().links.get()).toEqual({
id: 1,
serverUrl: "https://a.example",
});
});
});
+66
View File
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
syncLogger: { warn: vi.fn() },
}));
const { clearSyncProviders, hasSyncProvider, provideSync, syncForwarder } =
await import("../../sync/provider.js");
function response() {
const res = {
headersSent: false,
status: vi.fn(() => res),
json: vi.fn(() => res),
};
return res;
}
beforeEach(() => clearSyncProviders());
describe("sync forwarder", () => {
it("passes through when nothing provides sync", () => {
const next = vi.fn();
syncForwarder({} as never, response() as never, next);
expect(hasSyncProvider()).toBe(false);
expect(next).toHaveBeenCalledWith();
});
it("hands the request to the provider", () => {
const handle = vi.fn();
provideSync("a", { handle });
const next = vi.fn();
syncForwarder({} as never, response() as never, next);
expect(handle).toHaveBeenCalledOnce();
expect(next).not.toHaveBeenCalled();
});
it("moves on when the provider calls next", () => {
provideSync("a", { handle: (_req, _res, pass) => pass() });
const second = vi.fn();
provideSync("b", { handle: second });
syncForwarder({} as never, response() as never, vi.fn());
expect(second).toHaveBeenCalledOnce();
});
it("answers 500 when the provider throws", async () => {
provideSync("a", {
handle: async () => {
throw new Error("boom");
},
});
const res = response();
syncForwarder({} as never, res as never, vi.fn());
await Promise.resolve();
await Promise.resolve();
expect(res.status).toHaveBeenCalledWith(500);
});
it("stops forwarding after the provider is removed", () => {
const remove = provideSync("a", { handle: vi.fn() });
remove();
const next = vi.fn();
syncForwarder({} as never, response() as never, next);
expect(next).toHaveBeenCalledWith();
});
});
@@ -1,75 +0,0 @@
import { beforeEach, expect, it, vi } from "vitest";
const { listChanges } = vi.hoisted(() => ({ listChanges: vi.fn() }));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
}));
vi.mock("../../../utils/logger.js", () => ({ syncLogger: { error: vi.fn() } }));
vi.mock("../../../utils/app-version.js", () => ({ getLocalVersion: vi.fn() }));
vi.mock("../../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
vi.mock("../../../plugins/sync-registry.js", () => ({
getEntity: vi.fn(),
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
}));
vi.mock("../../../database/repositories/factory.js", () => ({}));
vi.mock("../../../sync/entities.js", () => ({
registerCoreSyncEntities: vi.fn(),
}));
vi.mock("../../../sync/records.js", () => ({
listChanges,
recordKey: vi.fn(),
}));
vi.mock("../../../sync/server/feed.js", () => ({
withUserLock: (_userId: string, fn: () => unknown) => fn(),
reconcileUser: async () => ({ wire: new Map() }),
}));
vi.mock("../../../sync/server/push.js", () => ({}));
vi.mock("../../../database/routes/branding-settings.js", () => ({}));
import router from "../../../sync/server/routes.js";
const route = router.stack.find(
(layer) => layer.route?.path === "/v2/changes",
)!.route!;
const handler = route.stack[route.stack.length - 1].handle;
beforeEach(() => {
vi.clearAllMocks();
listChanges.mockResolvedValue([]);
});
async function changes(query: Record<string, string>) {
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
await handler({ userId: "user-1", query } as never, res as never, vi.fn());
return res;
}
it("answers an empty page with the entity types past the newest change", async () => {
const cursor = String(Number.MAX_SAFE_INTEGER);
const res = await changes({ cursor, limit: "1000" });
expect(res.status).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({
changes: [],
nextCursor: Number.MAX_SAFE_INTEGER,
hasMore: false,
entityTypes: [{ type: "hosts", owner: "core", order: 10, readOnly: false }],
});
});
it("defaults a missing cursor and limit", async () => {
await changes({});
expect(listChanges).toHaveBeenCalledWith("user-1", 0, 1001, undefined);
});
it.each([
{ cursor: "abc" },
{ cursor: "-1" },
{ cursor: "1.5" },
{ cursor: "Infinity" },
{ limit: "1.5" },
{ limit: "-5" },
])("rejects a malformed query %o", async (query) => {
const res = await changes(query);
expect(res.status).toHaveBeenCalledWith(400);
expect(listChanges).not.toHaveBeenCalled();
});
@@ -1,74 +0,0 @@
import { beforeEach, expect, it, vi } from "vitest";
const { findHostIdBySyncId, canAccessHost } = vi.hoisted(() => ({
findHostIdBySyncId: vi.fn(),
canAccessHost: vi.fn(),
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
}));
vi.mock("../../../utils/logger.js", () => ({ syncLogger: { error: vi.fn() } }));
vi.mock("../../../utils/app-version.js", () => ({ getLocalVersion: vi.fn() }));
vi.mock("../../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({ findHostIdBySyncId }),
createCurrentHostRepository: () => ({
findById: async () => ({ name: "Shared host" }),
}),
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: { getInstance: () => ({ canAccessHost }) },
}));
vi.mock("../../../sync/entities.js", () => ({
registerCoreSyncEntities: vi.fn(),
}));
vi.mock("../../../sync/records.js", () => ({}));
vi.mock("../../../sync/server/feed.js", () => ({}));
vi.mock("../../../sync/server/push.js", () => ({}));
vi.mock("../../../database/routes/branding-settings.js", () => ({}));
import router from "../../../sync/server/routes.js";
const route = router.stack.find(
(layer) => layer.route?.path === "/v2/hosts/:syncId",
)!.route!;
const handler = route.stack[route.stack.length - 1].handle;
beforeEach(() => {
vi.clearAllMocks();
findHostIdBySyncId.mockResolvedValue(41);
canAccessHost.mockImplementation(async (_user, _host, action) => ({
hasAccess: action === "connect",
}));
});
async function lookup() {
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
await handler(
{ userId: "recipient", params: { syncId: "host-sync-id" } } as never,
res as never,
vi.fn(),
);
return res;
}
it("resolves the server ID for a connect-only recipient", async () => {
const res = await lookup();
expect(findHostIdBySyncId).toHaveBeenCalledWith("host-sync-id");
expect(canAccessHost).toHaveBeenCalledWith("recipient", 41, "connect");
expect(res.json).toHaveBeenCalledWith({ id: 41, name: "Shared host" });
});
it("does not disclose a host the caller cannot connect to", async () => {
canAccessHost.mockResolvedValue({ hasAccess: false });
const res = await lookup();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: "Not found" });
});
it("returns 404 for a missing sync ID", async () => {
findHostIdBySyncId.mockResolvedValue(null);
const res = await lookup();
expect(res.status).toHaveBeenCalledWith(404);
expect(canAccessHost).not.toHaveBeenCalled();
});
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const getLink = vi.hoisted(() => vi.fn());
vi.mock("../../sync/client/link-store.js", () => ({ getLink }));
const { shownIdentity } = await import("../../sync/shown-identity.js");
const local = { id: "u1", username: "server-ab12", isAdmin: true };
beforeEach(() => {
process.env.ELECTRON_EMBEDDED = "true";
getLink.mockReset();
});
afterEach(() => {
delete process.env.ELECTRON_EMBEDDED;
});
describe("who the app shows", () => {
it("shows the local user when nothing is linked", async () => {
getLink.mockResolvedValue(null);
expect(await shownIdentity(local)).toMatchObject({
username: "server-ab12",
isAdmin: true,
linked: null,
});
});
it("shows the account on the server once linked, with its own admin flag", async () => {
getLink.mockResolvedValue({
serverUrl: "https://home.example",
serverName: "Home",
remoteUsername: "luke",
account: { username: "luke", isAdmin: false },
status: "idle",
});
expect(await shownIdentity(local)).toMatchObject({
username: "luke",
isAdmin: false,
});
expect(getLink).toHaveBeenCalledWith("u1");
});
it("shows the sign in name until the account has synced", async () => {
getLink.mockResolvedValue({
serverUrl: "https://home.example",
remoteUsername: "luke",
account: null,
status: "syncing",
});
expect((await shownIdentity(local)).username).toBe("luke");
});
it("is the local user on a server", async () => {
delete process.env.ELECTRON_EMBEDDED;
expect((await shownIdentity(local)).username).toBe("server-ab12");
expect(getLink).not.toHaveBeenCalled();
});
});
-768
View File
@@ -1,768 +0,0 @@
/**
* A server and linked desktops, each with its own database, syncing through
* the real engine and the real /sync/v2 routes. Only the network, auth and
* the crypto keys are faked.
*/
import express from "express";
import request from "supertest";
import { and, eq, sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DatabaseContext } from "../../database/repositories/database-context.js";
const state = vi.hoisted(() => ({
current: null as null | { drizzle: unknown },
server: null as null | { drizzle: unknown },
allow: true,
sharedHostIds: new Set<number>(),
paths: [] as string[],
}));
const SERVER_USER = "srv-user";
vi.mock("../../database/db/index.js", () => ({
getDb: () => state.current!.drizzle,
getSqlite: () => null,
}));
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
batched: <T>(work: T) => work,
},
}));
vi.mock("../../utils/system-crypto.js", () => ({
SystemCrypto: {
getInstance: () => ({
getEncryptionKey: async () => Buffer.alloc(32, 7),
getJWTSecret: async () => "secret",
}),
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: () => Buffer.alloc(32, 3),
decryptRecord: (_table: string, record: unknown) => record,
},
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async () => state.allow,
canAccessHost: async (_userId: string, hostId: number) => ({
hasAccess: state.sharedHostIds.has(hostId),
}),
getUserPermissions: async () => [],
}),
},
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({
resyncHost: async () => {},
resyncHostsForCredential: async () => {},
}),
},
}));
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ resyncCredential: async () => {} }),
},
}));
vi.mock("../../hosts/delete-host.js", async () => {
const { hosts } = await import("../../database/db/schema.js");
const { eq: equals } = await import("drizzle-orm");
return {
deleteOwnedHost: async (_userId: string, id: number) => {
await (state.current!.drizzle as DatabaseContext["drizzle"])
.delete(hosts)
.where(equals(hosts.id, id));
return { id, name: "" };
},
};
});
vi.mock("../../hosts/delete-credential.js", async () => {
const { sshCredentials } = await import("../../database/db/schema.js");
const { eq: equals } = await import("drizzle-orm");
return {
deleteOwnedCredential: async (_userId: string, id: number) => {
await (state.current!.drizzle as DatabaseContext["drizzle"])
.delete(sshCredentials)
.where(equals(sshCredentials.id, id));
return { name: null };
},
};
});
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
req.userId = SERVER_USER;
next();
},
generateJWTToken: async () => "desktop-session",
revokeSession: async () => true,
}),
},
}));
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
vi.mock("../../sync/client/plugins.js", () => ({
mirrorPlugins: async () => {},
lastRemotePlugins: () => [],
isServerManaged: () => false,
}));
vi.mock("../../sync/client/http.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../sync/client/http.js")>();
return {
...actual,
// The event stream is not under test.
remoteFetch: () => new Promise(() => {}),
remoteJson: async (
_target: unknown,
path: string,
init: { method?: string; body?: unknown } = {},
) => {
state.paths.push(path);
const previous = state.current;
state.current = state.server;
try {
const agent = request(app);
const method = (init.method ?? "GET").toLowerCase() as "get" | "post";
const response = await agent[method](path).send(
init.body as object | undefined,
);
if (response.status >= 400) {
throw new actual.RemoteError(
"server",
response.body?.error ?? "error",
response.status,
);
}
return response.body;
} finally {
state.current = previous;
}
},
};
});
const { TestSqliteDatabase } =
await import("../database/repositories/test-support.js");
const schema = await import("../../database/db/schema.js");
const { default: syncRouter } = await import("../../sync/server/routes.js");
const { SyncEngine } = await import("../../sync/client/engine.js");
const { createLink, getLink, updateLink } =
await import("../../sync/client/link-store.js");
const { markChanged, reconcileUser, resetFeed } =
await import("../../sync/server/feed.js");
const { resetSeqAllocator, listConflicts, getConflict, deleteConflict } =
await import("../../sync/records.js");
const { createResolvers, writeWireRow } = await import("../../sync/store.js");
const { getEntity } = await import("../../plugins/sync-registry.js");
const { FieldCrypto } = await import("../../utils/field-crypto.js");
const { syncLogger } = await import("../../utils/logger.js");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
const app = express();
app.use(express.json({ limit: "5mb" }));
app.use("/sync", syncRouter);
type Db = InstanceType<typeof TestSqliteDatabase>;
interface Side {
db: Db;
ctx: DatabaseContext;
userId: string;
}
let server: Side;
let desktops: Side[] = [];
async function side(userId: string): Promise<Side> {
const db = new TestSqliteDatabase("sqlite");
const ctx = await db.connect();
await db.run(
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${userId}, ${userId}, '', 1)`,
);
return { db, ctx, userId };
}
async function on<T>(where: Side, fn: () => Promise<T>): Promise<T> {
const previous = state.current;
state.current = where.ctx;
try {
return await fn();
} finally {
state.current = previous;
}
}
function db(where: Side) {
return where.ctx.drizzle;
}
async function addHost(
where: Side,
values: Partial<typeof schema.hosts.$inferInsert> & { name: string },
) {
await db(where)
.insert(schema.hosts)
.values({
userId: where.userId,
ip: "10.0.0.1",
port: 22,
username: "root",
authType: "password",
syncId: `host-${values.name}`,
...values,
});
// Stands in for the change watcher a write request would trip.
if (where === server) markChanged();
}
async function hostsOf(where: Side) {
return db(where)
.select()
.from(schema.hosts)
.where(eq(schema.hosts.userId, where.userId));
}
async function linkDesktop(
desktop: Side,
): Promise<InstanceType<typeof SyncEngine>> {
await on(desktop, () =>
createLink({
userId: desktop.userId,
serverUrl: "https://termix.example",
sessionToken: "desktop-session",
}),
);
return new SyncEngine();
}
async function pass(desktop: Side, engine: InstanceType<typeof SyncEngine>) {
await on(desktop, () => engine.syncNow());
const link = await on(desktop, () => getLink());
expect(link?.lastError ?? null).toBeNull();
}
beforeEach(async () => {
resetFeed();
resetSeqAllocator();
state.allow = true;
state.sharedHostIds.clear();
state.paths = [];
server = await side(SERVER_USER);
state.server = server.ctx;
desktops = [await side("desk-a"), await side("desk-b")];
});
afterEach(async () => {
for (const s of [server, ...desktops]) await s.db.close();
});
describe("linking a desktop", () => {
it("merges both sides, translating references and keeping secrets", async () => {
const [desk] = desktops;
await on(desk, async () => {
await db(desk).insert(schema.sshCredentials).values({
userId: desk.userId,
name: "deploy key",
authType: "password",
username: "deploy",
syncId: "cred-1",
});
const [cred] = await db(desk).select().from(schema.sshCredentials);
await writeWireRow(
getEntity("hosts")!,
desk.userId,
{
syncId: "host-desk",
name: "desk",
ip: "10.0.0.2",
port: 22,
username: "root",
authType: "credential",
password: "s3cret",
credentialSyncId: cred.syncId,
},
createResolvers(desk.userId),
);
});
await on(server, () => addHost(server, { name: "srv" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
const serverHosts = await on(server, () => hostsOf(server));
expect(serverHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]);
const pushed = serverHosts.find((h) => h.name === "desk")!;
const [serverCred] = await db(server).select().from(schema.sshCredentials);
expect(serverCred.syncId).toBe("cred-1");
expect(pushed.credentialId).toBe(serverCred.id);
// Stored encrypted, never as plaintext.
expect(pushed.password).not.toBe("s3cret");
expect(pushed.password).toContain('"iv"');
const deskHosts = await on(desk, () => hostsOf(desk));
expect(deskHosts.map((h) => h.name).sort()).toEqual(["desk", "srv"]);
});
it("adopts the server's version of rows both sides already have", async () => {
const [desk] = desktops;
await on(desk, () => addHost(desk, { name: "same", ip: "old" }));
await on(server, () => addHost(server, { name: "same", ip: "new" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
const [host] = await on(desk, () => hostsOf(desk));
expect(host.ip).toBe("new");
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
});
it("never asks for a cursor past what the server's seq column holds", async () => {
const [desk] = desktops;
await on(server, () => addHost(server, { name: "srv" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
const cursors = state.paths
.filter((path) => path.startsWith("/sync/v2/changes?"))
.map((path) =>
Number(new URLSearchParams(path.split("?")[1]).get("cursor")),
);
expect(cursors.length).toBeGreaterThan(0);
expect(Math.max(...cursors)).toBeLessThanOrEqual(2_147_483_647);
});
});
describe("a synced host", () => {
it("publishes host.created when it is written for the first time only", async () => {
const [desk] = desktops;
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
const wire = {
syncId: "host-new",
name: "new",
ip: "10.0.0.2",
port: 22,
username: "root",
authType: "password",
};
try {
await on(desk, async () => {
const resolvers = createResolvers(desk.userId);
const first = await writeWireRow(
getEntity("hosts")!,
desk.userId,
wire,
resolvers,
);
await writeWireRow(
getEntity("hosts")!,
desk.userId,
{ ...wire, name: "renamed" },
resolvers,
);
expect(seen).toEqual([{ hostId: first.id, userId: desk.userId }]);
});
} finally {
off();
}
});
});
describe("after linking", () => {
it("sends edits and deletes both ways", async () => {
const [desk] = desktops;
await on(server, () => addHost(server, { name: "a" }));
await on(server, () => addHost(server, { name: "b" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
await on(desk, async () => {
await db(desk)
.update(schema.hosts)
.set({ ip: "10.9.9.9" })
.where(eq(schema.hosts.syncId, "host-a"));
});
await on(server, async () => {
await db(server)
.delete(schema.hosts)
.where(eq(schema.hosts.syncId, "host-b"));
markChanged();
});
await pass(desk, engine);
const serverHosts = await on(server, () => hostsOf(server));
expect(serverHosts.map((h) => [h.name, h.ip])).toEqual([["a", "10.9.9.9"]]);
const deskHosts = await on(desk, () => hostsOf(desk));
expect(deskHosts.map((h) => h.name)).toEqual(["a"]);
await on(desk, async () => {
await db(desk)
.delete(schema.hosts)
.where(eq(schema.hosts.syncId, "host-a"));
});
await pass(desk, engine);
expect(await on(server, () => hostsOf(server))).toEqual([]);
});
it("never sends a host kept on this device only", async () => {
const [desk] = desktops;
const engine = await linkDesktop(desk);
await pass(desk, engine);
await on(desk, () => addHost(desk, { name: "mine", localOnly: true }));
await pass(desk, engine);
expect(await on(server, () => hostsOf(server))).toEqual([]);
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
"mine",
]);
});
it("keeps a local edit that lost to a newer server edit, and can restore it", async () => {
const [desk] = desktops;
await on(server, () => addHost(server, { name: "a" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
await on(desk, () =>
db(desk)
.update(schema.hosts)
.set({ ip: "desk-edit" })
.where(eq(schema.hosts.syncId, "host-a")),
);
await on(server, async () => {
await db(server)
.update(schema.hosts)
.set({ ip: "server-edit" })
.where(eq(schema.hosts.syncId, "host-a"));
markChanged();
});
await pass(desk, engine);
const [local] = await on(desk, () => hostsOf(desk));
expect(local.ip).toBe("server-edit");
const conflicts = await on(desk, () => listConflicts(desk.userId));
expect(conflicts).toHaveLength(1);
expect(JSON.parse(conflicts[0].localRow).ip).toBe("desk-edit");
// Keep mine: put it back, and the next pass sends it.
await on(desk, async () => {
const conflict = (await getConflict(desk.userId, conflicts[0].id))!;
await writeWireRow(
getEntity("hosts")!,
desk.userId,
{ ...JSON.parse(conflict.localRow), syncId: conflict.syncId },
createResolvers(desk.userId),
);
await deleteConflict(desk.userId, conflict.id);
});
await pass(desk, engine);
const [serverHost] = await on(server, () => hostsOf(server));
expect(serverHost.ip).toBe("desk-edit");
});
it("does not let one refused change hold up the rest", async () => {
const [desk] = desktops;
const engine = await linkDesktop(desk);
await pass(desk, engine);
await on(desk, async () => {
await addHost(desk, { name: "one" });
await db(desk).insert(schema.sshCredentials).values({
userId: desk.userId,
name: "cred",
authType: "password",
syncId: "cred-x",
});
});
// The server refuses host changes only.
const hostPermissions = getEntity("hosts")!.permissions;
const credentialPermissions = getEntity("sshCredentials")!.permissions;
getEntity("sshCredentials")!.permissions = undefined;
state.allow = false;
try {
await pass(desk, engine);
} finally {
getEntity("hosts")!.permissions = hostPermissions;
getEntity("sshCredentials")!.permissions = credentialPermissions;
}
expect(await on(server, () => hostsOf(server))).toEqual([]);
const creds = await db(server).select().from(schema.sshCredentials);
expect(creds.map((c) => c.name)).toEqual(["cred"]);
const [record] = await on(desk, () =>
db(desk)
.select()
.from(schema.syncRecords)
.where(
and(
eq(schema.syncRecords.entityType, "hosts"),
eq(schema.syncRecords.syncId, "host-one"),
),
),
);
expect(record.error).toBe("permission");
});
it("leaves a switched-off kind of data alone, and catches up when it is back on", async () => {
const [desk] = desktops;
const engine = await linkDesktop(desk);
await pass(desk, engine);
const link = (await on(desk, () => getLink()))!;
await on(desk, () =>
updateLink({
disabledTypes: ["hosts"],
knownTypes: link.knownTypes.filter((type) => type !== "hosts"),
}),
);
await on(server, () => addHost(server, { name: "later" }));
await on(desk, () => addHost(desk, { name: "private" }));
await pass(desk, engine);
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
"private",
]);
expect(
(await on(server, () => hostsOf(server))).map((h) => h.name),
).toEqual(["later"]);
await on(desk, () => updateLink({ disabledTypes: [] }));
await pass(desk, engine);
await pass(desk, engine);
const names = (await on(desk, () => hostsOf(desk)))
.map((h) => h.name)
.sort();
expect(names).toEqual(["later", "private"]);
expect(
(await on(server, () => hostsOf(server))).map((h) => h.name).sort(),
).toEqual(["later", "private"]);
});
});
describe("a server row that cannot be read", () => {
async function recordOf(where: Side, syncId: string) {
const [record] = await on(where, () =>
db(where)
.select()
.from(schema.syncRecords)
.where(
and(
eq(schema.syncRecords.entityType, "hosts"),
eq(schema.syncRecords.syncId, syncId),
),
),
);
return record;
}
it.each(["upsert", "delete"] as const)(
"retries a local %s after the server row becomes readable",
async (op) => {
const [desk] = desktops;
await on(server, () => addHost(server, { name: "a" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
await on(desk, () =>
(op === "delete"
? db(desk).delete(schema.hosts)
: db(desk).update(schema.hosts).set({ ip: "desk-edit" })
).where(eq(schema.hosts.syncId, "host-a")),
);
await on(server, async () => {
await db(server)
.update(schema.hosts)
.set({ ip: "server-edit" })
.where(eq(schema.hosts.syncId, "host-a"));
await reconcileUser(SERVER_USER, { force: true });
// Then its secret stops decrypting, as under a different key.
const [host] = await hostsOf(server);
await db(server)
.update(schema.hosts)
.set({
password: FieldCrypto.encryptField(
"s3cret",
Buffer.alloc(32, 9),
String(host.id),
"password",
),
})
.where(eq(schema.hosts.id, host.id));
});
const warn = vi.spyOn(syncLogger, "warn");
try {
await pass(desk, engine);
expect(warn).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
entityType: "hosts",
syncId: "host-a",
error: expect.any(String),
}),
);
} finally {
warn.mockRestore();
}
const [local] = await on(desk, () => hostsOf(desk));
expect(local?.ip).toBe(op === "upsert" ? "desk-edit" : undefined);
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
expect((await recordOf(desk, "host-a")).error).toBe("unavailable");
// Restore the same server content without another local edit or a new
// server revision. The failed operation must still be retried.
await on(server, () =>
db(server)
.update(schema.hosts)
.set({ password: null })
.where(eq(schema.hosts.syncId, "host-a")),
);
await on(server, () => reconcileUser(SERVER_USER, { force: true }));
await pass(desk, engine);
const [after] = await on(desk, () => hostsOf(desk));
expect(after?.ip).toBe("server-edit");
expect((await recordOf(desk, "host-a")).error).toBeNull();
const conflicts = await on(desk, () => listConflicts(desk.userId));
expect(conflicts.map((c) => JSON.parse(c.localRow).ip)).toEqual(
op === "upsert" ? ["desk-edit"] : [],
);
},
);
it("keeps the local row when a conflict carries no row", async () => {
const [desk] = desktops;
await on(server, () => addHost(server, { name: "a" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
await on(desk, async () => {
const link = (await getLink())!;
const settle = (
engine as unknown as {
settle: (...args: unknown[]) => Promise<void>;
}
).settle.bind(engine);
await settle(
link,
{
entityType: "hosts",
syncId: "host-a",
baseRevision: 1,
op: "upsert",
row: { syncId: "host-a", name: "a" },
},
{
entityType: "hosts",
syncId: "host-a",
status: "conflict",
revision: 2,
deleted: false,
row: null,
},
"hash",
);
});
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
"a",
]);
const record = await recordOf(desk, "host-a");
expect(record.deleted).toBe(false);
expect(record.revision).toBe(1);
});
});
describe("hosts shared with the account", () => {
it("arrive on the desktop with their text columns as stored", async () => {
const [desk] = desktops;
await on(server, async () => {
await server.db.run(
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES ('owner', 'owner', '', 0)`,
);
await db(server).insert(schema.hosts).values({
userId: "owner",
name: "theirs",
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "none",
forceKeyboardInteractive: "true",
syncId: "host-theirs",
});
const [host] = await db(server).select().from(schema.hosts);
await db(server).insert(schema.hostAccess).values({
hostId: host.id,
userId: SERVER_USER,
grantedBy: "owner",
permissionLevel: "view",
});
state.sharedHostIds.add(host.id);
markChanged();
});
const engine = await linkDesktop(desk);
await pass(desk, engine);
const [copy] = await on(desk, () => hostsOf(desk));
expect(copy?.name).toBe("theirs");
expect(copy.forceKeyboardInteractive).toBe("true");
expect(JSON.parse(copy.sharedSource!)).toMatchObject({
owner: "owner",
permissionLevel: "view",
});
});
});
describe("two desktops on one account", () => {
it("converge on edits, creates and deletes made offline on each", async () => {
const [a, b] = desktops;
await on(server, () => addHost(server, { name: "shared-one" }));
await on(server, () => addHost(server, { name: "shared-two" }));
const engineA = await linkDesktop(a);
const engineB = await linkDesktop(b);
await pass(a, engineA);
await pass(b, engineB);
// Offline on both.
await on(a, () => addHost(a, { name: "from-a" }));
await on(a, () =>
db(a)
.update(schema.hosts)
.set({ ip: "10.1.1.1" })
.where(eq(schema.hosts.syncId, "host-shared-one")),
);
await on(b, () =>
db(b)
.delete(schema.hosts)
.where(eq(schema.hosts.syncId, "host-shared-two")),
);
await on(b, () => addHost(b, { name: "from-b" }));
await pass(a, engineA);
await pass(b, engineB);
await pass(a, engineA);
const view = async (where: Side) =>
(await on(where, () => hostsOf(where)))
.map((h) => `${h.name}:${h.ip}`)
.sort();
const expected = [
"from-a:10.0.0.1",
"from-b:10.0.0.1",
"shared-one:10.1.1.1",
];
expect(await view(server)).toEqual(expected);
expect(await view(a)).toEqual(expected);
expect(await view(b)).toEqual(expected);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
settings: new Map<string, string>(),
tlsEnabled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
getBoolean: async (key: string, fallback: boolean) =>
h.settings.has(key) ? h.settings.get(key) === "true" : fallback,
set: async (key: string, value: string) => {
h.settings.set(key, value);
},
}),
}));
vi.mock("../../tls/tls-service.js", () => ({
getTlsConfig: () => ({ enabled: h.tlsEnabled, port: 8443 }),
}));
const { httpsPolicyMiddleware, setForceHttps, resetHttpsPolicyForTests } =
await import("../../tls/https-policy.js");
function run(
path: string,
{ secure = false, headers = {} as Record<string, string> } = {},
) {
const all: Record<string, string> = { host: "termix.lan:8080", ...headers };
const req = {
secure,
path,
originalUrl: path,
url: path,
get: (name: string) => all[name.toLowerCase()],
};
const res = {
headers: {} as Record<string, string>,
redirected: null as null | { status: number; url: string },
setHeader(name: string, value: string) {
res.headers[name] = value;
},
redirect(status: number, url: string) {
res.redirected = { status, url };
},
};
const next = vi.fn();
return httpsPolicyMiddleware(req as never, res as never, next).then(() => ({
res,
next,
}));
}
beforeEach(() => {
h.settings.clear();
h.tlsEnabled = true;
resetHttpsPolicyForTests();
});
describe("https policy", () => {
it("is off by default", async () => {
const { res, next } = await run("/users/me");
expect(next).toHaveBeenCalled();
expect(res.redirected).toBeNull();
expect(res.headers).toEqual({});
});
it("does nothing while HTTPS is off, even when turned on", async () => {
await setForceHttps(true);
h.tlsEnabled = false;
const { res, next } = await run("/users/me");
expect(next).toHaveBeenCalled();
expect(res.redirected).toBeNull();
});
it("redirects plain HTTP to the HTTPS port", async () => {
await setForceHttps(true);
const { res, next } = await run("/users/me?x=1");
expect(next).not.toHaveBeenCalled();
expect(res.redirected).toEqual({
status: 308,
url: "https://termix.lan:8443/users/me?x=1",
});
});
it("trusts X-Forwarded-Proto and sends HSTS over HTTPS", async () => {
await setForceHttps(true);
const forwarded = await run("/users/me", {
headers: { "x-forwarded-proto": "https, http" },
});
expect(forwarded.next).toHaveBeenCalled();
expect(forwarded.res.headers["Strict-Transport-Security"]).toBe(
"max-age=31536000",
);
const direct = await run("/users/me", { secure: true });
expect(direct.next).toHaveBeenCalled();
expect(direct.res.headers["Strict-Transport-Security"]).toBeDefined();
});
it("leaves certificate checks and health probes on HTTP", async () => {
await setForceHttps(true);
for (const path of ["/.well-known/acme-challenge/abc", "/health"]) {
const { res, next } = await run(path);
expect(next).toHaveBeenCalled();
expect(res.redirected).toBeNull();
}
});
});
@@ -0,0 +1,144 @@
/**
* The audit forwarding settings migration.
*
* An upgrade must keep forwarding working: the collector URL and token have to
* reach the audit-tools plugin's settings (token encrypted) and be gone from
* the core settings table. Running it twice must not clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const state = vi.hoisted(() => ({
pluginRows: [] as Row[],
core: new Map<string, string>(),
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "audit-tools" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows = state.pluginRows.filter(
(row) => !(row.scope === scope && row.key === key),
);
state.pluginRows.push({ scope, scopeId, key, value, encrypted });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.core.get(key) ?? null,
delete: async (key: string) => {
state.core.delete(key);
},
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `enc(${value})`,
}));
import { runAuditForwardingMigration } from "../../upgrade/audit-forwarding-migration.js";
const row = (key: string) =>
state.pluginRows.find((entry) => entry.key === key);
beforeEach(() => {
state.pluginRows = [];
state.core = new Map();
state.pluginInstalled = true;
});
describe("audit forwarding migration", () => {
it("does nothing until the plugin exists", async () => {
state.pluginInstalled = false;
state.core.set("audit_log_forward_url", "https://siem.test/in");
expect(await runAuditForwardingMigration()).toEqual({
url: false,
token: false,
});
expect(state.core.get("audit_log_forward_url")).toBe(
"https://siem.test/in",
);
});
it("copies the url and encrypts the token, then clears core", async () => {
state.core.set("audit_log_forward_url", " https://siem.test/in ");
state.core.set("audit_log_forward_token", "secret");
expect(await runAuditForwardingMigration()).toEqual({
url: true,
token: true,
});
expect(JSON.parse(row("url")!.value!)).toBe("https://siem.test/in");
expect(row("url")!.encrypted).toBe(false);
expect(JSON.parse(row("token")!.value!)).toBe("enc(secret)");
expect(row("token")!.encrypted).toBe(true);
expect(state.core.size).toBe(0);
});
it("is safe to run twice", async () => {
state.core.set("audit_log_forward_url", "https://siem.test/in");
state.core.set("audit_log_forward_token", "secret");
await runAuditForwardingMigration();
const again = await runAuditForwardingMigration();
expect(again).toEqual({ url: false, token: false });
expect(state.pluginRows).toHaveLength(2);
});
it("keeps what the plugin already has", async () => {
state.pluginRows.push({
scope: "admin",
scopeId: null,
key: "url",
value: JSON.stringify("https://new.test/in"),
encrypted: false,
});
state.core.set("audit_log_forward_url", "https://old.test/in");
const result = await runAuditForwardingMigration();
expect(result.url).toBe(false);
expect(JSON.parse(row("url")!.value!)).toBe("https://new.test/in");
expect(state.core.has("audit_log_forward_url")).toBe(false);
});
it("drops a stray token when there is no url", async () => {
state.core.set("audit_log_forward_token", "orphan");
await runAuditForwardingMigration();
expect(state.core.has("audit_log_forward_token")).toBe(false);
expect(state.pluginRows).toHaveLength(0);
});
});
@@ -0,0 +1,86 @@
/**
* The status check interval moves into the host-status plugin once. A value
* the plugin already holds is kept, and running it twice changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
core: new Map<string, string>(),
plugin: new Map<string, string>(),
installed: true,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (h.installed ? { id } : null),
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => h.core.get(key) ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (_p: string, _s: string, _id: string | null, key: string) =>
h.plugin.has(key) ? { value: h.plugin.get(key) } : null,
set: async (
_p: string,
_s: string,
_id: string | null,
key: string,
value: string,
) => {
h.plugin.set(key, value);
},
}),
}));
const { runHostStatusSettingsMigration } =
await import("../../upgrade/host-status-settings-migration.js");
beforeEach(() => {
h.core.clear();
h.plugin.clear();
h.installed = true;
});
describe("host status settings migration", () => {
it("copies the interval into the plugin", async () => {
h.core.set("global_status_check_interval", "90");
expect(await runHostStatusSettingsMigration()).toBe(90);
expect(h.plugin.get("intervalSeconds")).toBe("90");
});
it("keeps a value the plugin already has", async () => {
h.core.set("global_status_check_interval", "90");
h.plugin.set("intervalSeconds", "45");
expect(await runHostStatusSettingsMigration()).toBeNull();
expect(h.plugin.get("intervalSeconds")).toBe("45");
});
it("does nothing twice", async () => {
h.core.set("global_status_check_interval", "120");
await runHostStatusSettingsMigration();
expect(await runHostStatusSettingsMigration()).toBeNull();
expect(h.plugin.get("intervalSeconds")).toBe("120");
});
it.each(["abc", "4", "7200", "1.5", ""])(
"ignores the invalid value %j",
async (value) => {
h.core.set("global_status_check_interval", value);
expect(await runHostStatusSettingsMigration()).toBeNull();
expect(h.plugin.has("intervalSeconds")).toBe(false);
},
);
it("does nothing without a stored interval or the plugin", async () => {
expect(await runHostStatusSettingsMigration()).toBeNull();
h.core.set("global_status_check_interval", "90");
h.installed = false;
expect(await runHostStatusSettingsMigration()).toBeNull();
expect(h.plugin.size).toBe(0);
});
});
@@ -1,125 +0,0 @@
import { describe, expect, it } from "vitest";
import {
escapeCsvField,
exportFilename,
toCsv,
toNdjson,
} from "../../utils/audit-export.js";
import type { AuditLogRecord } from "../../database/repositories/audit-log-repository.js";
function entry(overrides: Partial<AuditLogRecord> = {}): AuditLogRecord {
return {
id: 1,
userId: "u-1",
username: "alice",
action: "delete_host",
resourceType: "host",
resourceId: "9",
resourceName: "prod-db",
details: null,
ipAddress: "203.0.113.9",
userAgent: "Mozilla/5.0",
success: true,
errorMessage: null,
timestamp: "2026-07-28 10:00:00",
...overrides,
} as AuditLogRecord;
}
describe("escapeCsvField", () => {
it("leaves plain values alone", () => {
expect(escapeCsvField("prod-db")).toBe("prod-db");
expect(escapeCsvField(42)).toBe("42");
expect(escapeCsvField(true)).toBe("true");
});
it("renders null and undefined as empty", () => {
expect(escapeCsvField(null)).toBe("");
expect(escapeCsvField(undefined)).toBe("");
});
it("quotes and doubles embedded quotes", () => {
expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""');
});
it("quotes values containing commas or newlines", () => {
expect(escapeCsvField("a,b")).toBe('"a,b"');
expect(escapeCsvField("line1\nline2")).toBe('"line1\nline2"');
});
it("neutralises spreadsheet formulas", () => {
// An audit entry can carry an attacker-chosen resource name; without this
// the exported file executes it when opened.
expect(escapeCsvField("=1+1")).toBe("'=1+1");
expect(escapeCsvField("+SUM(A1)")).toBe("'+SUM(A1)");
expect(escapeCsvField("-2+3")).toBe("'-2+3");
expect(escapeCsvField("@import")).toBe("'@import");
});
it("still quotes a formula that also contains a comma", () => {
expect(escapeCsvField("=A1,B2")).toBe(`"'=A1,B2"`);
});
});
describe("toCsv", () => {
it("writes a header even with no rows", () => {
expect(toCsv([])).toBe(
"id,timestamp,username,userId,action,resourceType,resourceId,resourceName,success,ipAddress,userAgent,errorMessage,details\n",
);
});
it("writes one line per entry in column order", () => {
const lines = toCsv([entry(), entry({ id: 2, username: "bob" })])
.trim()
.split("\n");
expect(lines).toHaveLength(3);
expect(
lines[1].startsWith("1,2026-07-28 10:00:00,alice,u-1,delete_host"),
).toBe(true);
expect(lines[2].startsWith("2,")).toBe(true);
});
it("keeps a detached entry readable", () => {
const line = toCsv([entry({ userId: null })])
.trim()
.split("\n")[1];
// username survives so the row still names who acted.
expect(line).toContain("alice");
expect(line.split(",")[3]).toBe("");
});
});
describe("toNdjson", () => {
it("emits one parseable object per line", () => {
const out = toNdjson([entry(), entry({ id: 2 })]);
const parsed = out
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(parsed).toHaveLength(2);
expect(parsed[0].action).toBe("delete_host");
expect(parsed[1].id).toBe(2);
});
it("returns nothing for an empty set", () => {
expect(toNdjson([])).toBe("");
});
});
describe("exportFilename", () => {
it("is filesystem-safe and carries the timestamp", () => {
const name = exportFilename("csv", new Date("2026-07-28T10:11:12.000Z"));
expect(name).toBe("termix-audit-2026-07-28-10-11-12.csv");
expect(name).not.toMatch(/[:\s]/);
});
it("uses the ndjson extension for the streaming format", () => {
expect(exportFilename("ndjson", new Date("2026-07-28T10:11:12.000Z"))).toBe(
"termix-audit-2026-07-28-10-11-12.ndjson",
);
});
});
@@ -1,169 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const safeFetch = vi.hoisted(() => vi.fn());
const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() }));
vi.mock("../../database/repositories/factory.js", () => ({
getCurrentSettingValue: () => null,
}));
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: safeFetch,
}));
vi.mock("../../utils/logger.js", () => ({ databaseLogger: logs }));
const {
auditForwardTarget,
forwardAuditEntry,
forwardPayload,
resetAuditForwarderState,
AUDIT_FORWARD_URL_ENV,
AUDIT_FORWARD_TOKEN_ENV,
} = await import("../../utils/audit-forwarder.js");
const ENTRY = {
userId: "u-1",
username: "alice",
action: "delete_host",
resourceType: "host",
resourceId: "9",
success: true,
ipAddress: "203.0.113.9",
};
const NOW = new Date("2026-07-28T10:00:00.000Z");
beforeEach(() => {
safeFetch.mockReset();
logs.info.mockReset();
logs.warn.mockReset();
resetAuditForwarderState();
});
describe("auditForwardTarget", () => {
it("is off unless a URL is configured", () => {
expect(auditForwardTarget({})).toBeNull();
expect(auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: " " })).toBeNull();
});
it("carries an optional bearer token", () => {
expect(
auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest" }),
).toEqual({ url: "https://siem/ingest" });
expect(
auditForwardTarget({
[AUDIT_FORWARD_URL_ENV]: "https://siem/ingest",
[AUDIT_FORWARD_TOKEN_ENV]: "secret",
}),
).toEqual({ url: "https://siem/ingest", token: "secret" });
});
});
describe("forwardPayload", () => {
it("matches the export shape, with absent fields as null", () => {
expect(forwardPayload(ENTRY, NOW)).toEqual({
timestamp: "2026-07-28T10:00:00.000Z",
userId: "u-1",
username: "alice",
action: "delete_host",
resourceType: "host",
resourceId: "9",
resourceName: null,
success: true,
ipAddress: "203.0.113.9",
userAgent: null,
errorMessage: null,
details: null,
});
});
});
describe("forwardAuditEntry", () => {
const env = { [AUDIT_FORWARD_URL_ENV]: "https://siem.example/ingest" };
it("does nothing when forwarding is not configured", async () => {
await expect(forwardAuditEntry(ENTRY, NOW, {})).resolves.toBe(false);
expect(safeFetch).not.toHaveBeenCalled();
});
it("posts one NDJSON line through the SSRF-checked fetch", async () => {
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(true);
const [url, init] = safeFetch.mock.calls[0];
expect(url).toBe("https://siem.example/ingest");
expect(init.method).toBe("POST");
expect(init.headers["Content-Type"]).toBe("application/x-ndjson");
expect(init.headers.Authorization).toBeUndefined();
expect(JSON.parse(init.body.trim()).action).toBe("delete_host");
expect(init.body.endsWith("\n")).toBe(true);
});
it("sends the bearer token when one is set", async () => {
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
await forwardAuditEntry(ENTRY, NOW, {
...env,
[AUDIT_FORWARD_TOKEN_ENV]: "secret",
});
expect(safeFetch.mock.calls[0][1].headers.Authorization).toBe(
"Bearer secret",
);
});
it("reports a rejected delivery without throwing", async () => {
safeFetch.mockResolvedValueOnce({ ok: false, status: 503 });
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
expect(logs.warn).toHaveBeenCalledWith(
"Failed to forward audit entry",
expect.objectContaining({ reason: "collector returned 503" }),
);
});
it("swallows transport errors, a dead SIEM must not break auditing", async () => {
safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
expect(logs.warn).toHaveBeenCalledWith(
"Failed to forward audit entry",
expect.objectContaining({ reason: "ECONNREFUSED" }),
);
});
it("stops repeating itself once the collector is persistently down", async () => {
safeFetch.mockResolvedValue({ ok: false, status: 500 });
for (let i = 0; i < 8; i++) {
await forwardAuditEntry(ENTRY, NOW, env);
}
// 5 per-entry warnings, then one suppression notice, not 8.
const perEntry = logs.warn.mock.calls.filter(
(call) => call[0] === "Failed to forward audit entry",
);
expect(perEntry).toHaveLength(5);
expect(
logs.warn.mock.calls.some((call) =>
String(call[0]).includes("suppressing further messages"),
),
).toBe(true);
// It keeps trying regardless.
expect(safeFetch).toHaveBeenCalledTimes(8);
});
it("announces recovery after a suppressed outage", async () => {
safeFetch.mockResolvedValue({ ok: false, status: 500 });
for (let i = 0; i < 6; i++) await forwardAuditEntry(ENTRY, NOW, env);
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
await forwardAuditEntry(ENTRY, NOW, env);
expect(logs.info).toHaveBeenCalledWith(
"Audit forwarding recovered",
expect.objectContaining({ operation: "audit_forward_recovered" }),
);
});
});
@@ -8,6 +8,8 @@ const encryptionKey = crypto.randomBytes(32);
const mocks = vi.hoisted(() => ({
hasUserDEK: vi.fn(() => false),
adoptRecoveredDEK: vi.fn(async () => {}),
revoke: vi.fn(async () => true),
created: [] as Array<Record<string, unknown>>,
}));
vi.mock("../../database/db/index.js", () => ({
@@ -19,10 +21,27 @@ vi.mock("../../database/db/index.js", () => ({
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentSessionRepository: () => ({
findById: async (id: string) =>
id === "expired"
? { id, expiresAt: new Date(Date.now() - 1000).toISOString() }
: { id },
findById: async (id: string) => {
const minutesAgo = (n: number) =>
new Date(Date.now() - n * 60_000).toISOString();
if (id === "expired") {
return { id, expiresAt: new Date(Date.now() - 1000).toISOString() };
}
if (id === "idle") {
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(11) };
}
if (id === "active") {
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(5) };
}
if (id === "setup") return { id, restriction: "second-factor-setup" };
return { id };
},
create: async (row: Record<string, unknown>) => {
mocks.created.push(row);
return row;
},
revoke: mocks.revoke,
touch: async () => true,
}),
createCurrentUserRepository: () => ({}),
createCurrentApiKeyRepository: () => ({}),
@@ -54,7 +73,9 @@ vi.mock("../../utils/system-crypto.js", () => ({
},
}));
const { AuthManager } = await import("../../utils/auth-manager.js");
const { AuthManager, allowsSetupSession } =
await import("../../utils/auth-manager.js");
const { registerSecondFactor } = await import("../../auth/registry.js");
const authManager = AuthManager.getInstance();
function makeLegacyDataKeyWrap(
@@ -74,6 +95,8 @@ function makeLegacyDataKeyWrap(
}
beforeEach(() => {
mocks.created.length = 0;
mocks.revoke.mockClear();
mocks.hasUserDEK.mockReset().mockReturnValue(false);
mocks.adoptRecoveredDEK.mockReset().mockResolvedValue(undefined);
});
@@ -191,6 +214,30 @@ describe("AuthManager token handling", () => {
expect(payload?.userId).toBe("user-1");
});
it("gives an API key a ticket on a short session that only a socket takes", async () => {
mocks.created.length = 0;
const ticket = await authManager.issueApiKeySocketTicket(
"user-1",
"key-12345678",
);
const payload = jwt.decode(ticket) as Record<string, number | string>;
expect(payload.userId).toBe("user-1");
expect(payload.purpose).toBe("ws");
expect(Number(payload.exp) - Number(payload.iat)).toBe(60);
const [session] = mocks.created;
expect(session.userId).toBe("user-1");
expect(session.id).toBe(payload.sessionId);
expect(session.deviceInfo).toBe("api-key: socket key-1234");
const lifetime =
new Date(String(session.expiresAt)).getTime() -
new Date(String(session.createdAt)).getTime();
expect(lifetime).toBe(120_000);
expect(await authManager.verifyJWTToken(ticket)).toBeNull();
});
it("rejects a socket ticket whose session has expired", async () => {
const ticket = await authManager.issueSocketTicket("user-1", "expired");
expect(
@@ -209,3 +256,97 @@ describe("AuthManager token handling", () => {
).toBeNull();
});
});
describe("login policy on sessions", () => {
const sign = (sessionId: string) =>
jwt.sign({ userId: "user-1", sessionId }, jwtSecret, { expiresIn: "1h" });
it("caps the lifetime and stores the idle timeout and restriction", async () => {
const token = await authManager.generateJWTToken("user-1", {
rememberMe: true,
deviceType: "web",
deviceInfo: "Firefox",
maxAgeSeconds: 600,
idleTimeoutSeconds: 300,
restriction: "second-factor-setup",
});
const payload = jwt.decode(token) as { iat: number; exp: number };
expect(payload.exp - payload.iat).toBe(600);
expect(mocks.created[0]).toMatchObject({
idleTimeout: 300,
restriction: "second-factor-setup",
});
const lifetime =
new Date(mocks.created[0].expiresAt as string).getTime() -
new Date(mocks.created[0].createdAt as string).getTime();
expect(lifetime).toBe(600_000);
});
it("ends a session idle past its timeout and keeps an active one", async () => {
expect(await authManager.verifyJWTToken(sign("idle"))).toBeNull();
expect(mocks.revoke).toHaveBeenCalledWith("idle");
expect(await authManager.verifyJWTToken(sign("active"))).not.toBeNull();
});
it("only lets a setup session reach setup routes", async () => {
const middleware = authManager.createAuthMiddleware();
const call = async (url: string) => {
const res = {
statusCode: 200,
body: undefined as unknown,
status(code: number) {
res.statusCode = code;
return res;
},
json(body: unknown) {
res.body = body;
return res;
},
clearCookie() {
return res;
},
};
const next = vi.fn();
await middleware(
{
cookies: { jwt: sign("setup") },
headers: {},
originalUrl: url,
url,
} as never,
res as never,
next,
);
return { res, next };
};
const blocked = await call("/host/db/host");
expect(blocked.res.statusCode).toBe(403);
expect(blocked.res.body).toMatchObject({
code: "SECOND_FACTOR_SETUP_REQUIRED",
});
expect(blocked.next).not.toHaveBeenCalled();
const me = await call("/users/me");
expect(me.next).toHaveBeenCalled();
});
it("allows second factor plugins' routes and nothing else", () => {
const dispose = registerSecondFactor({
id: "key",
pluginId: "keys",
labelKey: "key",
isEnrolled: async () => false,
verify: async () => true,
});
try {
expect(allowsSetupSession("/plugin-api/keys/register")).toBe(true);
expect(allowsSetupSession("/plugin-api/other/thing")).toBe(false);
expect(allowsSetupSession("/users/logout")).toBe(true);
expect(allowsSetupSession("/users/me/token")).toBe(false);
expect(allowsSetupSession("/credentials")).toBe(false);
} finally {
dispose();
}
});
});
@@ -10,6 +10,7 @@ import {
isPrivateKeyPassphraseError,
getFriendlyKeyTypeName,
validateKeyPair,
isSecurityKeyFile,
} from "../../utils/ssh-key-utils.js";
const { parsePrivateKey } = sshpk;
@@ -224,3 +225,43 @@ describe("PKCS#8 runtime key normalization", () => {
).toThrow();
});
});
describe("security key files", () => {
const str = (v: string | Buffer) => {
const b = Buffer.from(v);
const l = Buffer.alloc(4);
l.writeUInt32BE(b.length);
return Buffer.concat([l, b]);
};
const skFile = (type: string) => {
const body = Buffer.concat([
Buffer.from("openssh-key-v1\u0000"),
str("none"),
str("none"),
str(""),
Buffer.from([0, 0, 0, 1]),
str(Buffer.concat([str(type), str(Buffer.alloc(32, 1)), str("ssh:")])),
str(Buffer.alloc(16)),
]).toString("base64");
return `-----BEGIN OPENSSH PRIVATE KEY-----
${body}
-----END OPENSSH PRIVATE KEY-----`;
};
it("detects sk key files", () => {
expect(isSecurityKeyFile(skFile("sk-ssh-ed25519@openssh.com"))).toBe(true);
expect(
isSecurityKeyFile(skFile("sk-ecdsa-sha2-nistp256@openssh.com")),
).toBe(true);
});
it("ignores normal keys", () => {
expect(isSecurityKeyFile(ED25519_PRIVATE)).toBe(false);
});
it("explains how to use a security key instead of failing to decode", () => {
expect(() =>
preparePrivateKeyForSSH2(skFile("sk-ssh-ed25519@openssh.com")),
).toThrow("ssh-add");
});
});