mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
v26.10.3 (#1692)
* 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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+9
-23
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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> }>,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
-1
@@ -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
-1
@@ -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', ?)",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user