* fix: keep concurrent ssh pool connects from overwriting each other (#1619)

* fix: stop auto-accepting changed jump host keys (#1620)

A jump hop whose key no longer matched the pinned fingerprint was
silently accepted and the pin overwritten. Background status probes
reconnect to jump hosts every cycle, so a MITM on that hop got the
credentials with no user interaction. Changed jump host keys now go
through the same path as any other host: prompt when there is a
terminal socket, otherwise refuse.

* fix(electron): validate external login URL scheme and callback port (#1621)

* fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622)

The consent check read the first manifest.json with a hand-written tar
reader, while the unpack keeps the last one and also follows pax and
long-name headers. An archive with two manifests could pass the check
with one capability list and get granted another. Read the manifest
with node-tar, count only plain files, and refuse archives that hold
more than one manifest.json.

* fix: only trust the server's origin in the desktop login frame (#1624)

The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it.

The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent.

* fix: check trusted proxy login against nginx's peer, not nginx (#1625)

The backend only sees the bundled nginx on loopback, so the allowlist
could only work by trusting 127.0.0.1, which let any client that reached
nginx log in as anyone by sending the identity headers. nginx now passes
its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and
loopback connections are judged by that address instead.

* fix: keep foreign keys on during user data import (#1626)

Import credentials before hosts and remap the file's credential ids to
the ones created here, so hosts and protocol logins no longer point at
another server's ids. With rows written in dependency order, the import
no longer turns off SQLite foreign keys on the shared connection. The
uploaded file is now removed on every exit path.

* fix: enforce unique usernames (#1628)

* fix: enforce unique usernames

Registration checked for a taken name, hashed the password, then
inserted, with nothing in the schema enforcing uniqueness. Concurrent
sign-ups could create two accounts with one name, after which login,
password reset, proxy login and admin lookups by username picked
between them arbitrarily.

Add a unique index on users.username for every dialect. Existing
duplicates are renamed (later rows get a -<id> suffix, the oldest keeps
the name) rather than deleted. Registration and admin user creation now
return the same 409 when the insert loses the race.

* fix: avoid username collisions during migration

* fix: don't auto-enable synced plugins signed only by a server-sent key (#1623)

* fix: don't auto-enable synced plugins signed only by a server-sent key

Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust.

* fix: enforce local plugin trust across remote source labels

* fix: require positive local trust for synced plugin keys

* fix(desktop): harden external editor and session cookie IPC (#1627)

* fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie

open-external-editor now launches only the editor saved from a file dialog
and refuses to hand executable file types to the OS opener. The unused
get-session-cookie and wait-session-cookie channels, which returned the
httpOnly jwt, are removed.

* fix: validate setting keys before editor authorization

* fix(sidebar): sort hosts by IP address numerically (#1629)

* fix(desktop): find spawn-helper in universal macOS builds (#1630)

The universal build ships app-arm64.asar and app-x64.asar instead of
app.asar, so node-pty's helper path rewrite never pointed at the
unpacked copy and every local terminal failed with posix_spawnp failed.

* fix(sync): send shared hosts with text columns in their stored form (#1631)

resolveHostById turns forceKeyboardInteractive into a boolean, and the
shared-host export passed it on as is. A desktop could not bind the boolean
into the text column, so every shared host failed to apply.

* fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633)

The vault plugin renames vault_profiles to p_vault_profiles, and SQLite
rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then
dropped p_vault_profiles and left ssh_data pointing at a missing table, so
creating, cloning and deleting hosts failed with "no such table:
main.p_vault_profiles". Postgres and MySQL refused the uninstall instead.

The column was already copied into the vault plugin's host settings by 2.9,
so retire it on every engine. The SQLite boot drop also repairs databases
already in the broken state; MySQL drops the column's foreign key first.

* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632)

* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own

* fix(sync): switch off an untrusted mirrored plugin when the server updates it

* fix: require local consent for mirrored plugin activation

* docs: describe consent fields on the plugin state endpoint

* fix(i18n): keep Indonesian technical terms consistent (#1636)

Use the English terms Indonesian users know for tunnel, snippet, deploy,
file manager, remote desktop, jump host, passphrase, sidebar, toolbar,
endpoint, browser and SSH agent, instead of mixing literal translations
(terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the
English words. Match the split shortcuts to the split screen menu.

Also fix the user data export description, which said secrets are
encrypted while the English says they are decrypted.

* fix(settings): leave user manage view when switching admin sections (#1649)

The admin panel stays mounted across Settings pages, so an open user
manage view kept replacing every other admin section until Back to users
was clicked. Reset it whenever the selected section changes.

* fix(sync): don't delete a local row when the server can't send its copy (#1650)

* fix(sync): don't delete a local row when the server can't send its copy

A push conflict on a live record whose server row failed to serialize
came back as row: null, deleted: false, and the desktop treated that as
a delete. The server now rejects that op as unavailable, the desktop
never deletes on a conflict without a row, and serialization failures
are logged.

* fix(sync): retry temporarily unavailable records

* fix: smooth host editor jumps to lower sections

* chore: add changelog entries for 26.10.2 fixes

* fix: stop building ssh2 native crypto in the docker image (#1644)

* chore: test that an update keeps a changed auth type (#1601)

* feat: option to hide the sidebar after opening a host (#1652)

* feat: sort the dashboard host status card (Plugin-Host-Metrics#6)

* fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2)

* fix: say the server is down and keep retrying instead of a database error (#1637)

* fix: show why a client tunnel failed to start (Plugin-Tunnels#2)

* fix(sdk): skip comment-only chunks when splitting migrations

* feat: setting to let the hover side menu push content again

* fix: use the vite proxy for plugin sockets on any dev port

* feat(sdk): let a plugin raise its public route rate limit

* chore: update translations

* chore: add the overview video to the readme

* chore: update the overview video and add a play button to its thumbnail

* feat: plugin builds bundle worker entries and import wasm and fonts as bytes

* feat: plugin packs include LICENSE and NOTICE

* chore: drop guacd from compose files and dev runner

* chore: update remote desktop origin comment for the built-in engine

* fix: show plugin names and descriptions in the UI language (#1658)

* fix: show plugin names and descriptions in the UI language

Read plugin.name and plugin.description from each plugin's own locale
namespace, falling back to the manifest, everywhere core shows a plugin's
name: the Plugins manager, onboarding picker, host editor tabs and
sections, host filters and bulk actions, beta updates, interface presets
and plugin crash/loading notices.

* docs(sdk): document plugin.name and plugin.description locale keys

---------

Co-authored-by: ZacharyZcR <zkl205305@gmail.com>

* fix(hosts): reject or infer a missing authType on host create (#1664)

Creating an SSH host through the API without authType hit the NOT NULL
auth_type column and came back as a 500. The create route now infers the
type from the credential, key or password in the body, falls back to the
host defaults, and otherwise answers 400. A non-string authType is a 400
on create and update.

* chore: changelog for merged plugin name and host auth type PRs

* fix: move macOS split pane navigation to Cmd+Option+Arrow

* fix: pick a free port for the desktop backend when 30001 is taken (#1663)

* feat: build the Windows desktop app for ARM64 (#1615)

* fix(sync): keep the changes cursor within the 32-bit seq column (#1665)

The desktop learned the server's entity types by asking for changes after
Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres,
which rejects that bound value, so every pass needing the probe failed with
22003 out of range.

The server now clamps the cursor to the largest seq the column holds and
answers 400 for a cursor or limit that is not a non-negative integer. The
desktop asks for a one-row page from its saved cursor instead.

* fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667)

Successful plugin_as_user and plugin_service_* entries written by a plugin
with the same user, action, resource and details are recorded once per
10 minutes. Failures and every other action are still written each time.

* feat(plugins): publish host.created when a host is added (#1668)

Plugins that keep an inventory of hosts had no way to hear about a new
one short of polling. Core now publishes host.created with the same
{ hostId, userId } payload as host.updated wherever a host row is
created: the host create route, ctx.hosts.create, JSON and SSH config
bulk import, database file import, and sync writing a host it did not
have yet.

* fix: show the login for signed-out full-screen view links and return to them (#1669)

A ?view= link opened without a session spun forever: the gate rendered
the bare view when the session check failed, so the plugin runtime never
started and the view never registered. A regular browser now keeps the
link in sessionStorage, shows the normal login and opens the link again
after any login, including redirect logins. Only same-origin paths with
a view are restored. The desktop app, embedded sign-in frames and guest
views keep their current behavior.

* fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670)

* fix(desktop): send linked server proxy headers on WebSocket upgrades

isLinkedOrigin compared the request origin with the link's http(s)
origin, so the terminal WebSocket (wss://host/...) never matched and its
upgrade went out without the proxy headers and basic auth. Behind a
forward-auth proxy that upgrade was redirected to the login and the
terminal closed with "connection closed unexpectedly". ws and wss now
count as http and https for the comparison, the same mapping the
WebSocket handshake itself uses, with host and port still matched
exactly.

* fix(desktop): allow a linked server's self-signed certificate for its WebSockets

isInvalidCertificateAllowedForUrl compared the URL with a local
getOrigin copy, so wss://host never matched the link's https://host
origin and an allowed self-signed certificate did not cover the
terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin,
which maps ws and wss the same way as the proxy headers, and the local
copy is removed.

* feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666)

ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background
work a session for calling Termix's own API as an existing user. It needs
users:impersonate, defaults to 300s and is capped at 900s, is stored as a
normal session row marked as plugin-issued, and every issue is audited.
Such sessions are refused on routes that change the user's password,
second factors, API keys or linked logins.

ctx.users.getLocale(userId) reads the user's UI language under users:read.

SDK 1.0.11, plugin API 1.3.0.

* chore: changelog for merged sync, audit, event and desktop PRs

* feat: add shell.rightEdge slot so plugin panels can push the page over

* chore: pin bundled plugins to the latest releases

* chore: lint, format, and bump version to 26.10.2

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Ferdina Kusumah <19812460+FerdinaKusumah@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zkl205305@gmail.com>
Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-18 00:34:50 -05:00
1 parent 3517b2f0ed
commit 66e8ce6df7
227 files changed
+20364 -1340

No files matched your search

@@ -0,0 +1,109 @@
import fs from "fs";
import os from "os";
import path from "path";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Registration checked for a taken username before inserting, with nothing in
* the schema to back it up, so two concurrent sign-ups could both get the same
* name. Startup renames the later duplicates instead of deleting anyone, then
* makes the name unique.
*/
describe("users.username uniqueness", () => {
let dataDir: string;
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-unique-user-"));
vi.resetModules();
process.env.DATA_DIR = dataDir;
process.env.DB_FILE_ENCRYPTION = "false";
process.env.ALLOW_EMPTY_DATA_DIR = "true";
});
afterEach(() => {
delete process.env.DATA_DIR;
delete process.env.DB_FILE_ENCRYPTION;
delete process.env.ALLOW_EMPTY_DATA_DIR;
fs.rmSync(dataDir, { recursive: true, force: true });
});
function writeDatabaseWithDuplicates(): void {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash, registered_at) VALUES
('late', 'alice', 'hash', '2025-03-01 00:00:00'),
('first', 'alice', 'hash', '2025-01-01 00:00:00'),
('tie-b', 'bob', 'hash', '2025-01-01 00:00:00'),
('tie-a', 'bob', 'hash', '2025-01-01 00:00:00'),
('only', 'Alice', 'hash', '2025-02-01 00:00:00');
`);
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
seed.close();
}
async function bootAndGetSqlite(): Promise<Database.Database> {
const db = await import("../../../database/db/index.js");
await db.initializeDatabase();
return db.getSqlite();
}
it("keeps the oldest holder of a name and renames the rest", async () => {
writeDatabaseWithDuplicates();
const sqlite = await bootAndGetSqlite();
const names = Object.fromEntries(
sqlite
.prepare("SELECT id, username FROM users")
.raw()
.all() as [string, string][],
);
expect(names).toEqual({
first: "alice",
late: "termix-recovered-0-5",
"tie-a": "bob",
"tie-b": "termix-recovered-0-3",
only: "Alice",
});
});
it("rejects a second user with a taken name", async () => {
const sqlite = await bootAndGetSqlite();
const insert = sqlite.prepare(
"INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)",
);
insert.run("u1", "alice", "hash");
expect(() => insert.run("u2", "alice", "hash")).toThrow(/UNIQUE/i);
});
});
// Run the checked-in migration itself, including occupied recovery prefixes.
it("migrates duplicate names without colliding with existing names", () => {
const db = new Database(":memory:");
try {
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL, registered_at TEXT NOT NULL);
INSERT INTO users VALUES
('first', 'alice', '2025-01-01'),
('late', 'alice', '2025-02-01'),
('other', 'alice-late', '2025-01-01'),
('occupied', 'termix-recovered-0-1', '2025-01-01');`);
db.exec(fs.readFileSync("drizzle/sqlite/0067_unique_username.sql", "utf8"));
const rows = db.prepare("SELECT username FROM users").pluck().all();
expect(new Set(rows).size).toBe(4);
expect(rows).toContain("alice");
expect(rows).toContain("alice-late");
expect(rows).toContain("termix-recovered-0-1");
expect(() => db.exec("INSERT INTO users VALUES ('new', 'alice', '2026-01-01')")).toThrow(/UNIQUE/i);
} finally { db.close(); }
});
@@ -0,0 +1,89 @@
import fs from "fs";
import os from "os";
import path from "path";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ssh_data.vault_profile_id points at vault_profiles. The vault plugin renames
// that table to p_vault_profiles, which SQLite follows into ssh_data's foreign
// key, so uninstalling the plugin used to leave every host write failing with
// "no such table: main.p_vault_profiles".
describe("ssh_data and the vault plugin's tables", () => {
let dataDir: string;
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-vault-fk-"));
vi.resetModules();
vi.stubEnv("DATA_DIR", dataDir);
vi.stubEnv("DB_FILE_ENCRYPTION", "false");
vi.stubEnv("ALLOW_EMPTY_DATA_DIR", "true");
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(dataDir, { recursive: true, force: true });
});
function seed(afterAdoption: string): void {
const seed = new Database(":memory:");
seed.exec(
fs.readFileSync(
new URL("../../fixtures/sqlite-2.8-schema.sql", import.meta.url),
"utf8",
),
);
seed.exec(`
CREATE TABLE plugin_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
migration_id TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (plugin_id, migration_id),
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
);
INSERT INTO users (id, username, password_hash)
VALUES ('owner', 'alice', 'hash');
INSERT INTO ssh_data (id, user_id, ip, port, username, auth_type)
VALUES (1, 'owner', '192.0.2.1', 22, 'root', 'password');
ALTER TABLE vault_profiles RENAME TO p_vault_profiles;
ALTER TABLE vault_tokens RENAME TO p_vault_tokens;
${afterAdoption}
`);
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
seed.close();
}
async function boot() {
const db = await import("../../../database/db/index.js");
await db.initializeDatabase();
return db.getSqlite();
}
function writeHosts(sqlite: Database.Database): void {
sqlite
.prepare(
"INSERT INTO ssh_data (user_id, ip, port, username, auth_type) VALUES ('owner', '192.0.2.2', 22, 'root', 'password')",
)
.run();
sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run();
}
it("repairs a database the plugin was already uninstalled from", async () => {
seed("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;");
const sqlite = await boot();
expect(() => writeHosts(sqlite)).not.toThrow();
});
it("leaves nothing in ssh_data pointing at the plugin", async () => {
seed("");
const sqlite = await boot();
expect(
sqlite.prepare("PRAGMA foreign_key_list(ssh_data)").all(),
).not.toContainEqual(expect.objectContaining({ table: "p_vault_profiles" }));
sqlite.exec("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;");
expect(() => writeHosts(sqlite)).not.toThrow();
});
});
@@ -0,0 +1,164 @@
import * as fs from "node:fs";
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runInNewContext } from "node:vm";
import Database from "better-sqlite3";
import ts from "typescript";
import { expect, it, vi } from "vitest";
const source = readFileSync(
new URL("../../database/database.ts", import.meta.url),
"utf8",
);
const ast = ts.createSourceFile(
"database.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const route = ast.statements.find(
(s) =>
ts.isExpressionStatement(s) &&
ts.isCallExpression(s.expression) &&
s.expression.arguments[0]?.getText(ast) === '"/database/import"',
) as ts.ExpressionStatement;
const batched = (route.expression as ts.CallExpression)
.arguments[3] as ts.CallExpression;
const callback = batched.arguments[0];
const dir = mkdtempSync(join(tmpdir(), "termix-import-route-"));
function exportFile(): string {
const path = join(dir, `export-${Math.random()}.db`);
const db = new Database(path);
db.exec(`
CREATE TABLE ssh_credentials (id INTEGER, name TEXT, username TEXT);
CREATE TABLE ssh_data (id INTEGER, ip TEXT, port INTEGER, username TEXT, credential_id INTEGER);
INSERT INTO ssh_credentials VALUES (7, 'fresh', 'root'), (8, 'kept', 'admin');
INSERT INTO ssh_data VALUES
(1, '10.0.0.1', 22, 'root', 7),
(2, '10.0.0.2', 22, 'root', 8),
(3, '10.0.0.3', 22, 'root', 99),
(4, '10.0.0.4', 22, 'root', NULL);
`);
db.close();
return path;
}
async function invoke(path: string, user: unknown = { isExternal: false }) {
const writes: string[] = [];
const events: Array<[string, unknown]> = [];
const hosts: Array<Record<string, unknown>> = [];
const importHostProtocolLogins = vi.fn();
const handler = runInNewContext(
ts.transpileModule(`(${callback.getText(ast)})`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText,
{
fs,
Buffer,
Database,
parseUserAgent: () => ({ type: "web" }),
createCurrentUserRepository: () => ({ findById: async () => user }),
DataCrypto: { getUserDataKey: () => Buffer.from("key") },
apiLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
createCurrentCredentialRepository: () => ({
findIdForImportIdentity: async (_: string, name: string) =>
name === "kept" ? 42 : null,
createEncryptedForUser: async () => {
writes.push("credential");
return { id: 100 };
},
}),
createCurrentHostRepository: () => ({
existsForImportIdentity: async () => false,
createEncryptedForUser: async (_: string, host: never) => {
writes.push("host");
hosts.push(host);
return { id: 500 + hosts.length };
},
}),
legacyStatusCheck: () => ({}),
sshOptionsForWrite: () => null,
applyHostDefaultsToWrite: async () => ({}),
applyPluginHostImportSettings: async () => {},
importedHostPluginSettings: () => ({}),
importHostProtocolLogins,
applyDefaultsAfterHostWrites: async () => {},
importUserPluginRows: async () => ({
imported: 0,
skipped: 0,
errors: [],
}),
importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
DatabaseSaveTrigger: { forceSave: async () => {} },
getErrorMessage: (error: Error) => error.message,
pluginEvents: {
emit: (topic: string, payload: unknown) =>
events.push([topic, payload]),
},
TOPICS: { hostCreated: "host.created" },
},
);
const res = { json: vi.fn(), status: vi.fn() };
res.status.mockReturnValue(res);
await handler(
{ userId: "user-1", file: { path, originalname: "export.db" } },
res,
);
return { res, writes, hosts, events, importHostProtocolLogins };
}
it("points imported hosts at the credentials imported with them", async () => {
const { res, writes, hosts, importHostProtocolLogins } =
await invoke(exportFile());
expect(res.status).not.toHaveBeenCalled();
expect(writes).toEqual(["credential", "host", "host", "host", "host"]);
expect(hosts.map((host) => host.credentialId)).toEqual([100, 42, null, null]);
expect([...importHostProtocolLogins.mock.calls[0][4]]).toEqual([
[7, 100],
[8, 42],
]);
});
it("publishes host.created once for each imported host", async () => {
const { events } = await invoke(exportFile());
expect(events).toEqual(
[501, 502, 503, 504].map((hostId) => [
"host.created",
{ hostId, userId: "user-1" },
]),
);
});
it("removes the uploaded file when the user is gone", async () => {
const path = exportFile();
const { res } = await invoke(path, null);
expect(res.status).toHaveBeenCalledWith(404);
expect(existsSync(path)).toBe(false);
});
it("removes the uploaded file when it is not a SQLite database", async () => {
const path = join(dir, "not-sqlite.db");
writeFileSync(path, "definitely not a database");
const { res } = await invoke(path);
expect(res.status).toHaveBeenCalledWith(400);
expect(existsSync(path)).toBe(false);
});
it("removes the uploaded file after a successful import", async () => {
const path = exportFile();
await invoke(path);
expect(existsSync(path)).toBe(false);
});
@@ -213,7 +213,7 @@ describe("HostRepository and CredentialRepository", () => {
it("checks credential import identity", async () => {
const repo = await createRepositories();
await repo.credentials.create({
const created = await repo.credentials.create({
userId: "user-1",
name: "primary",
authType: "password",
@@ -221,11 +221,11 @@ describe("HostRepository and CredentialRepository", () => {
});
await expect(
repo.credentials.existsForImportIdentity("user-1", "primary", "root"),
).resolves.toBe(true);
repo.credentials.findIdForImportIdentity("user-1", "primary", "root"),
).resolves.toBe(created.id);
await expect(
repo.credentials.existsForImportIdentity("user-1", "primary", "admin"),
).resolves.toBe(false);
repo.credentials.findIdForImportIdentity("user-1", "primary", "admin"),
).resolves.toBeNull();
});
it("renames credential folders through the write boundary", async () => {
@@ -1,51 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const factory = vi.hoisted(() => ({
getCurrentRepositorySqlite: vi.fn(),
}));
vi.mock("../../../database/repositories/factory.js", () => factory);
import {
withCurrentSqliteForeignKeysDisabled,
withSqliteForeignKeysDisabled,
} from "../../../database/repositories/sqlite-foreign-keys.js";
const previousDatabaseDialect = process.env.DATABASE_DIALECT;
afterEach(() => {
if (previousDatabaseDialect === undefined)
delete process.env.DATABASE_DIALECT;
else process.env.DATABASE_DIALECT = previousDatabaseDialect;
vi.clearAllMocks();
});
describe("withSqliteForeignKeysDisabled", () => {
it("restores foreign keys after an import", async () => {
const sqlite = { exec: vi.fn() };
await expect(
withSqliteForeignKeysDisabled(sqlite, async () => "imported"),
).resolves.toBe("imported");
expect(sqlite.exec.mock.calls).toEqual([
["PRAGMA foreign_keys = OFF"],
["PRAGMA foreign_keys = ON"],
]);
});
});
describe("withCurrentSqliteForeignKeysDisabled", () => {
it.each(["postgres", "mysql"])(
"runs portable imports with constraints enabled on %s",
async (dialect) => {
process.env.DATABASE_DIALECT = dialect;
const operation = vi.fn().mockResolvedValue("imported");
await expect(
withCurrentSqliteForeignKeysDisabled(operation),
).resolves.toBe("imported");
expect(operation).toHaveBeenCalledOnce();
expect(factory.getCurrentRepositorySqlite).not.toHaveBeenCalled();
},
);
});
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { sql } from "drizzle-orm";
import { TestSqliteDatabase } from "./test-support.js";
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
const state = vi.hoisted(() => ({
context: null as DatabaseContext | null,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentRepositoryContext: () => state.context,
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { triggerSave: vi.fn() },
}));
const { listChanges } = await import("../../../sync/records.js");
// Runs against Postgres and MySQL too in the database-dialects CI job, where
// sync_records.seq is a 32-bit integer.
describe("listChanges", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
state.context = null;
});
async function seed(): Promise<void> {
adapter = new TestSqliteDatabase();
state.context = await adapter.connect();
await adapter.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'user-1', '')`,
);
await adapter.run(
sql`INSERT INTO sync_records (user_id, entity_type, sync_id, revision, seq) VALUES ('user-1', 'hosts', 'a', 1, 1), ('user-1', 'hosts', 'b', 1, 2)`,
);
}
it("lists records after the cursor in seq order", async () => {
await seed();
const records = await listChanges("user-1", 1, 10);
expect(records.map((record) => record.syncId)).toEqual(["b"]);
});
it("answers no records for a cursor past what seq can hold", async () => {
await seed();
expect(await listChanges("user-1", Number.MAX_SAFE_INTEGER, 10)).toEqual(
[],
);
});
});
@@ -0,0 +1,44 @@
import fs from "node:fs";
import { sql } from "drizzle-orm";
import { expect, it } from "vitest";
import { TestSqliteDatabase, testDialect } from "./test-support.js";
it("migrates duplicate usernames without colliding with occupied recovery names", async () => {
const adapter = new TestSqliteDatabase();
await adapter.connect();
try {
await adapter.exec(`CREATE TABLE username_migration_fixture (
id varchar(255) PRIMARY KEY, username TEXT NOT NULL, registered_at varchar(255) NOT NULL
);
INSERT INTO username_migration_fixture VALUES
('first', 'alice', '2025-01-01'),
('late', 'alice', '2025-02-01'),
('other', 'alice-late', '2025-01-01'),
('occupied', 'termix-recovered-0-1', '2025-01-01');`);
const dialect = testDialect();
const number = dialect === "sqlite" ? "0067" : "0072";
const migration = fs
.readFileSync(`drizzle/${dialect}/${number}_unique_username.sql`, "utf8")
.replace(/\busers\b/g, "username_migration_fixture")
.replaceAll("idx_users_username", "idx_fixture_username");
for (const statement of migration.split("--> statement-breakpoint")) {
if (statement.trim()) await adapter.run(sql.raw(statement));
}
const rows = await adapter.query<{ username: string }>(
sql`SELECT username FROM username_migration_fixture`,
);
const names = rows.map((row) => row.username);
expect(new Set(names).size).toBe(4);
expect(names).toEqual(
expect.arrayContaining(["alice", "alice-late", "termix-recovered-0-1"]),
);
await expect(async () => {
await adapter.run(
sql`INSERT INTO username_migration_fixture VALUES ('new', 'alice', '2026-01-01')`,
);
}).rejects.toThrow();
} finally {
await adapter.exec("DROP TABLE IF EXISTS username_migration_fixture");
await adapter.close();
}
});
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { UserRepository } from "../../../database/repositories/user-repository.js";
import {
UserRepository,
isUsernameTaken,
} from "../../../database/repositories/user-repository.js";
describe("UserRepository.listPage", () => {
let adapter: TestSqliteDatabase | null = null;
@@ -93,3 +96,27 @@ describe("UserRepository.listPage", () => {
expect(page.total).toBe(5);
});
});
describe("UserRepository.create", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
it("rejects a taken username in a way callers can recognise", async () => {
adapter = new TestSqliteDatabase();
const repo = new UserRepository(await adapter.connect());
await repo.create({ id: "u1", username: "alice", passwordHash: "hash" });
const error = await repo
.create({ id: "u2", username: "alice", passwordHash: "hash" })
.catch((e: unknown) => e);
expect(isUsernameTaken(error)).toBe(true);
expect(isUsernameTaken(new Error("unrelated"))).toBe(false);
});
});
@@ -143,7 +143,7 @@ describe("notifyImportedHosts", () => {
seen.push(payload);
});
try {
notifyImportedHosts("user-1", [4, 7]);
notifyImportedHosts("user-1", [4, 7], []);
} finally {
off();
}
@@ -0,0 +1,216 @@
/**
* The create and update host routes against a real SQLite database. A body
* that leaves out authType must never reach the NOT NULL auth_type column.
*/
import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import request from "supertest";
import { 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(() => ({
ctx: null as null | { drizzle: unknown },
}));
const USER = "user-1";
const pass = (_req: Request, _res: Response, next: NextFunction) => next();
vi.mock("../../../database/db/index.js", () => ({
getDb: () => state.ctx!.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),
validateUserAccess: () => Buffer.alloc(32, 3),
encryptRecord: (_table: string, record: unknown) => record,
decryptRecord: (_table: string, record: unknown) => record,
decryptRecords: (_table: string, records: unknown) => records,
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: Request, _res: Response, next: NextFunction) => {
(req as Request & { userId: string }).userId = USER;
next();
},
createDataAccessMiddleware: () => pass,
createAdminMiddleware: () => pass,
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
requirePermission: () => pass,
canAccessHost: async () => ({ hasAccess: true, isOwner: true }),
hasPermission: async () => true,
isAdmin: async () => true,
getUserPermissions: async () => [],
}),
},
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async () => {},
getAuditUsername: async () => USER,
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const { TestSqliteDatabase } = await import("../repositories/test-support.js");
const { hosts, sshCredentials } =
await import("../../../database/db/schema.js");
const { saveLevel } = await import("../../../hosts/defaults/service.js");
const { default: hostRouter } =
await import("../../../database/routes/host.js");
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
const app = express();
app.use(express.json());
app.use("/host", hostRouter);
let db: InstanceType<typeof TestSqliteDatabase>;
let ctx: DatabaseContext;
beforeEach(async () => {
db = new TestSqliteDatabase("sqlite");
ctx = await db.connect();
state.ctx = ctx;
await ctx.drizzle.run(
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${USER}, ${USER}, '', 1)`,
);
});
afterEach(async () => {
await db.close();
});
async function addCredential(): Promise<number> {
const [row] = await ctx.drizzle
.insert(sshCredentials)
.values({
userId: USER,
name: "ops",
username: "root",
authType: "password",
password: "secret",
})
.returning({ id: sshCredentials.id });
return row.id;
}
async function storedAuthType(id: number) {
const [row] = await ctx.drizzle
.select({ authType: hosts.authType })
.from(hosts)
.where(eq(hosts.id, id));
return row?.authType;
}
const base = { ip: "10.0.0.5", port: 22, username: "root" };
describe("host create without authType", () => {
it("infers credential auth from a credentialId", async () => {
const credentialId = await addCredential();
const res = await request(app)
.post("/host/db/host")
.send({ ...base, credentialId });
expect(res.status).toBe(200);
expect(await storedAuthType(res.body.id)).toBe("credential");
});
it("infers password auth from a password", async () => {
const res = await request(app)
.post("/host/db/host")
.send({ ...base, password: "pw" });
expect(res.status).toBe(200);
expect(await storedAuthType(res.body.id)).toBe("password");
});
it("rejects a body that says nothing about authentication", async () => {
const res = await request(app).post("/host/db/host").send(base);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/authType is required/);
});
it("takes the auth type from the user's host defaults", async () => {
const credentialId = await addCredential();
await saveLevel(
{ level: "user", userId: USER },
{
set: new Map([["core.auth", { authType: "credential", credentialId }]]),
unset: new Set(),
errors: {},
},
USER,
);
const res = await request(app).post("/host/db/host").send(base);
expect(res.status).toBe(200);
expect(await storedAuthType(res.body.id)).toBe("credential");
});
});
describe("host update authType", () => {
it("keeps the stored auth type when the update leaves it out", async () => {
const created = await request(app)
.post("/host/db/host")
.send({ ...base, authType: "password", password: "pw" });
const res = await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(res.status).toBe(200);
expect(await storedAuthType(created.body.id)).toBe("password");
});
it("rejects an authType that is not a string", async () => {
const created = await request(app)
.post("/host/db/host")
.send({ ...base, authType: "password", password: "pw" });
const res = await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, authType: { bad: true } });
expect(res.status).toBe(400);
});
});
describe("host.created", () => {
it("is published once when a host is created, and not on update", async () => {
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
try {
const created = await request(app)
.post("/host/db/host")
.send({ ...base, authType: "password", password: "pw" });
expect(created.status).toBe(200);
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(seen).toEqual([{ hostId: created.body.id, userId: USER }]);
} finally {
off();
}
});
});
@@ -36,7 +36,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload.js";
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
let handler: RequestHandler;
let sshConfigHandler: RequestHandler;
@@ -259,3 +260,53 @@ it("does not save dependent hosts when importing their jump host fails", async (
);
expect(mocks.create).toHaveBeenCalledTimes(1);
});
function collectCreated() {
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
return { seen, off };
}
it("publishes host.created once for each host a JSON import creates", async () => {
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
mocks.create.mockResolvedValueOnce({ id: 501 });
const { seen, off } = collectCreated();
try {
await importHosts([host, { ...host, ip: "192.0.2.2" }], true);
} finally {
off();
}
// The overwritten host already existed, so only the new one is announced.
expect(seen).toEqual([{ hostId: 501, userId: "user-1" }]);
});
it("publishes host.created once for each host an SSH config import creates", async () => {
mocks.create
.mockResolvedValueOnce({ id: 601 })
.mockResolvedValueOnce({ id: 602 });
const { seen, off } = collectCreated();
try {
await sshConfigHandler(
{
userId: "user-1",
body: {
content:
"Host a\n HostName 10.0.0.1\n User root\nHost b\n HostName 10.0.0.2\n User root\n",
},
} as unknown as Request,
{
json: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response,
vi.fn(),
);
} finally {
off();
}
expect(seen).toEqual([
{ hostId: 601, userId: "user-1" },
{ hostId: 602, userId: "user-1" },
]);
});
@@ -3,6 +3,7 @@ import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js"
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
impliedSshAuthType,
isNonEmptyString,
keepsStoredSshAuth,
isOptionalBoolean,
@@ -515,3 +516,16 @@ describe("keepsStoredSshAuth", () => {
expect(keepsStoredSshAuth("none")).toBe(false);
});
});
describe("impliedSshAuthType", () => {
it("reads the auth type from the secret a create body carries", () => {
expect(impliedSshAuthType({ credentialId: 2, password: "pw" })).toBe(
"credential",
);
expect(impliedSshAuthType({ key: "-----BEGIN", password: "pw" })).toBe(
"key",
);
expect(impliedSshAuthType({ password: "pw" })).toBe("password");
expect(impliedSshAuthType({})).toBeUndefined();
});
});
@@ -176,6 +176,7 @@ const manageMock = vi.hoisted(() => ({
deletePluginData: vi.fn(),
planStateChange: vi.fn(),
setPluginState: vi.fn(),
enablePluginWithConsent: vi.fn(),
getDeveloperMode: vi.fn(),
setDeveloperMode: vi.fn(),
stageUpload: vi.fn(),
@@ -215,6 +216,12 @@ const onboardingMock = vi.hoisted(() => ({
vi.mock("../../../plugins/onboarding.js", () => onboardingMock);
const syncPluginsMock = vi.hoisted(() => ({
isServerManaged: vi.fn((_id: string) => true),
}));
vi.mock("../../../sync/client/plugins.js", () => syncPluginsMock);
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
.default;
@@ -1139,6 +1146,65 @@ describe("plugins route", () => {
expect(manageMock.installPlugin).not.toHaveBeenCalled();
});
describe("on a linked desktop", () => {
const switchOn = (id: string) =>
fetch(`${baseUrl}/plugins/${id}/state`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
enabled: true,
version: "1.0.0",
capabilities: ["kv:own"],
}),
});
beforeEach(() => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
state.plugins.set("sample-plugin", makePlugin({ state: "disabled" }));
});
afterEach(() => {
syncPluginsMock.isServerManaged.mockImplementation(() => true);
});
it("refuses to switch a plugin the server decides", async () => {
const res = await switchOn("sample-plugin");
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("MANAGED_BY_SERVER");
expect(manageMock.setPluginState).not.toHaveBeenCalled();
});
it("lets the desktop's admin switch one the server does not", async () => {
syncPluginsMock.isServerManaged.mockImplementation(() => false);
manageMock.enablePluginWithConsent.mockResolvedValue({
enable: [],
disable: [],
});
const res = await switchOn("sample-plugin");
expect(res.status).toBe(200);
expect(manageMock.enablePluginWithConsent).toHaveBeenCalledWith(
"sample-plugin",
expect.objectContaining({
version: "1.0.0",
capabilities: ["kv:own"],
}),
);
});
it("says per plugin whether the server decides it", async () => {
state.plugins.set("other", makePlugin({ id: "other" }));
syncPluginsMock.isServerManaged.mockImplementation(
(id) => id === "other",
);
const listed = (await (
await fetch(`${baseUrl}/plugins`)
).json()) as Array<{ id: string; managedByServer: boolean }>;
expect(
Object.fromEntries(listed.map((p) => [p.id, p.managedByServer])),
).toEqual({ "sample-plugin": false, other: true });
});
});
it("previews a state change without making it", async () => {
state.plugins.set("sample-plugin", makePlugin());
manageMock.planStateChange.mockResolvedValue({
@@ -145,6 +145,35 @@ describe("client tunnel runtime authentication", () => {
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" } });
@@ -0,0 +1,65 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import vm from "node:vm";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const linkedServer = require("../../../../electron/linked-server.cjs") as {
setLinkedServer: (config: unknown) => void;
};
const source = fs.readFileSync(
new URL("../../../../electron/main.cjs", import.meta.url),
"utf8",
);
const tls = source.slice(
source.indexOf("const INSECURE_MODE_VALUES"),
source.indexOf("function httpFetch("),
);
const ORIGIN = "https://termix.example";
const TERMINAL = "wss://termix.example/plugin-ws/ssh-terminal/terminal";
function load(link: Record<string, unknown> | null) {
linkedServer.setLinkedServer(link);
const context = vm.createContext({
URL,
process: { env: {} },
linkedServer,
});
vm.runInContext(tls, context);
return context as unknown as {
isInvalidCertificateAllowedForUrl: (url: string) => boolean;
getWebSocketOptions: (
url: string,
options?: Record<string, unknown>,
) => Record<string, unknown>;
};
}
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: {},
rejectUnauthorized: false,
});
});
it.each([
"ws://termix.example/relay",
"wss://termix.example:8443/relay",
"wss://elsewhere.example/relay",
])("keeps verifying %s", (url) => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
expect(main.isInvalidCertificateAllowedForUrl(url)).toBe(false);
});
it("keeps verifying when the link does not allow it", () => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: false });
expect(main.getWebSocketOptions(TERMINAL)).toEqual({
rejectUnauthorized: true,
});
});
});
@@ -39,6 +39,48 @@ describe("linked server in Electron main", () => {
).toEqual({ Accept: "x" });
});
it("treats WebSocket upgrades to the linked server as the linked origin", () => {
linked.setLinkedServer({ origin: ORIGIN });
expect(linked.isLinkedOrigin(`${ORIGIN}/host/status`)).toBe(true);
expect(
linked.isLinkedOrigin("wss://termix.example/plugin-ws/ssh-terminal"),
).toBe(true);
expect(linked.isLinkedOrigin("wss://termix.example:443/")).toBe(true);
expect(linked.isLinkedOrigin("ws://termix.example/")).toBe(false);
expect(linked.isLinkedOrigin("wss://termix.example:8443/")).toBe(false);
expect(linked.isLinkedOrigin("wss://other.example/")).toBe(false);
expect(linked.isLinkedOrigin("not a url")).toBe(false);
linked.setLinkedServer({ origin: "http://termix.example:8080" });
expect(linked.isLinkedOrigin("ws://termix.example:8080/ws")).toBe(true);
expect(linked.isLinkedOrigin("wss://termix.example:8080/ws")).toBe(false);
expect(linked.isLinkedOrigin("ws://termix.example/ws")).toBe(false);
});
it("adds proxy headers and basic auth to the terminal WebSocket", () => {
linked.setLinkedServer({
origin: ORIGIN,
headers: [{ name: "CF-Access-Client-Id", value: "id" }],
basicAuth: { username: "gate", password: "pw" },
});
expect(
linked.applyLinkHeaders(
"wss://termix.example/plugin-ws/ssh-terminal/terminal",
{ Upgrade: "websocket" },
),
).toEqual({
Upgrade: "websocket",
"CF-Access-Client-Id": "id",
Authorization: `Basic ${Buffer.from("gate:pw").toString("base64")}`,
});
expect(
linked.answerLogin({}, "wss://termix.example/plugin-ws/ssh-terminal"),
).toEqual({ username: "gate", password: "pw" });
expect(
linked.applyLinkHeaders("wss://other.example/", { Upgrade: "websocket" }),
).toEqual({ Upgrade: "websocket" });
});
it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
linked.setLinkedServer({
origin: ORIGIN,
@@ -80,4 +80,20 @@ describe("repairSpawnHelpers", () => {
expect(chmods[0][1] & 0o111).not.toBe(0);
expect(chmods[0][0]).toContain("app.asar.unpacked");
});
it.each(["app-arm64.asar", "app-x64.asar"])(
"repairs the helper in the unpacked copy of a universal build's %s",
(asar) => {
const chmods: string[] = [];
const fsImpl = {
statSync: () => ({ mode: 0o644 }),
chmodSync: (file: string) => {
chmods.push(file.split(require("node:path").sep).join("/"));
},
};
const dir = `/Applications/Termix.app/Contents/Resources/${asar}/node_modules/node-pty`;
repairSpawnHelpers(dir, fsImpl);
expect(chmods[0]).toContain(`/${asar}.unpacked/node_modules/node-pty/`);
},
);
});
@@ -11,7 +11,48 @@ import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { chmodSpawnHelpers } = require("../../../../scripts/patch-node-pty.cjs");
const {
chmodSpawnHelpers,
patchUnixTerminalHelperRewrite,
} = require("../../../../scripts/patch-node-pty.cjs");
const upstreamRewrite = [
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
].join("\n");
const previouslyPatchedRewrite = [
"if (!helperPath.includes('app.asar.unpacked')) {",
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"}",
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
"}",
].join("\n");
const resources = "/Applications/Termix.app/Contents/Resources";
const helperPaths = [
["app.asar", "app.asar.unpacked"],
["app-arm64.asar", "app-arm64.asar.unpacked"],
["app-x64.asar", "app-x64.asar.unpacked"],
["app.asar.unpacked", "app.asar.unpacked"],
["app-arm64.asar.unpacked", "app-arm64.asar.unpacked"],
].map(([from, to]) => [
`${resources}/${from}/node_modules/node-pty/build/Release/spawn-helper`,
`${resources}/${to}/node_modules/node-pty/build/Release/spawn-helper`,
]);
function writeUnixTerminal(rewrite: string) {
const file = join(
tmpdir(),
`termix-unix-terminal-${Date.now()}-${Math.random()}.cjs`,
);
writeFileSync(
file,
`module.exports = function (helperPath) {\n${rewrite}\nreturn helperPath;\n};\n`,
);
return file;
}
// chmod's execute bit is a POSIX concept; Windows/NTFS has no equivalent mode bit.
const itPosix = process.platform === "win32" ? it.skip : it;
@@ -30,4 +71,22 @@ describe("patch-node-pty", () => {
expect(chmodSpawnHelpers(root)).toBe(0);
expect(existsSync(helper)).toBe(true);
});
it.each([
["upstream", upstreamRewrite],
["previously patched", previouslyPatchedRewrite],
])(
"points the %s helper path at the unpacked copy of any app asar",
(_, rewrite) => {
const file = writeUnixTerminal(rewrite);
expect(patchUnixTerminalHelperRewrite(file)).toBe(true);
expect(patchUnixTerminalHelperRewrite(file)).toBe(false);
const rewriteHelperPath = require(file);
for (const [from, to] of helperPaths) {
expect(rewriteHelperPath(from)).toBe(to);
}
},
);
});
@@ -106,6 +106,31 @@ describe("applyHostDefaultsToWrite", () => {
expect(overrides.core).toEqual(["statusCheckEnabled", "username"]);
});
it("keeps a changed auth type on an update without a list", async () => {
userDefault("auth", { authType: "credential", credentialId: 3 });
const stored = {
id: 5,
userId: "u1",
authType: "credential",
credentialId: 3,
defaultOverrides: JSON.stringify({ core: ["username"] }),
};
const columns: Record<string, unknown> = {
authType: "vault",
credentialId: null,
overrideCredentialUsername: 0,
};
const overrides = await applyHostDefaultsToWrite({
ownerId: "u1",
hostId: 5,
columns,
body: { authType: "vault", credentialId: 0 },
stored: stored as never,
});
expect(columns.authType).toBe("vault");
expect(overrides.core).toContain("auth");
});
it("keeps a locked key's state for a shared editor", async () => {
const overrides = await applyHostDefaultsToWrite({
ownerId: "u1",
@@ -1,10 +1,16 @@
import { EventEmitter } from "node:events";
import type { WebSocket } from "ws";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
const repo = vi.hoisted(() => ({
updateHostKey: vi.fn(async () => {}),
storeHostKey: vi.fn(async () => {}),
touchHostKeyLastVerified: vi.fn(async () => {}),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({}),
createCurrentHostResolutionRepository: () => repo,
}));
vi.mock("../../utils/logger.js", () => ({
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
@@ -57,3 +63,50 @@ describe("quick connect host keys", () => {
expect(await verifyUnsaved(null)).toBe(true);
});
});
const pinned = {
hostKeyFingerprint: Buffer.from("old-key").toString("hex"),
hostKeyType: "ssh-ed25519",
hostKeyAlgorithm: "sha256",
hostKeyChangedCount: 0,
name: "bastion",
};
async function verifyJumpHop(socket: WebSocket | null, key: string) {
const verifier = await SSHHostKeyVerifier.createHostVerifier(
7,
"10.0.0.1",
22,
socket,
"user",
true,
pinned,
);
return new Promise<boolean>((resolve) => verifier(Buffer.from(key), resolve));
}
describe("jump host key changes", () => {
beforeEach(() => {
repo.updateHostKey.mockClear();
});
it("refuses a changed key and keeps the pin when nobody can be asked", async () => {
expect(await verifyJumpHop(null, "new-key")).toBe(false);
expect(repo.updateHostKey).not.toHaveBeenCalled();
});
it("asks the user and only replaces the pin when they accept", async () => {
const no = socketAnswering("reject");
expect(await verifyJumpHop(no.socket, "new-key")).toBe(false);
expect(no.sent[0]).toContain("host_key_changed");
expect(repo.updateHostKey).not.toHaveBeenCalled();
const yes = socketAnswering("accept");
expect(await verifyJumpHop(yes.socket, "new-key")).toBe(true);
expect(repo.updateHostKey).toHaveBeenCalledTimes(1);
});
it("still accepts the pinned key without asking", async () => {
expect(await verifyJumpHop(null, "old-key")).toBe(true);
});
});
@@ -42,6 +42,52 @@ describe("SSHConnectionPool", () => {
pool.destroy();
});
it("tracks every client from concurrent connects to a new host", async () => {
const pool = new SSHConnectionPool();
const clients = Array.from({ length: 3 }, deferredClient);
let factoryCalls = 0;
const factory = vi.fn(() => clients[factoryCalls++].promise);
const requests = Array.from({ length: 3 }, () =>
pool.getConnection("new-host", factory),
);
for (const pending of clients) pending.resolve(pending.client);
const connected = await Promise.all(requests);
for (const client of connected) pool.releaseConnection("new-host", client);
const reused = await Promise.all(
Array.from({ length: 3 }, () => pool.getConnection("new-host", factory)),
);
expect(new Set(reused)).toEqual(new Set(connected));
expect(factory).toHaveBeenCalledTimes(3);
pool.clearKeyConnections("new-host");
for (const pending of clients) {
expect(pending.client.end).toHaveBeenCalledOnce();
}
pool.destroy();
});
it("does not resurrect a connection removed while another connects", async () => {
const pool = new SSHConnectionPool();
const first = deferredClient();
const second = deferredClient();
first.resolve(first.client);
await pool.getConnection("host", () => first.promise);
const request = pool.getConnection("host", () => second.promise);
first.client.emit("close");
second.resolve(second.client);
await expect(request).resolves.toBe(second.client);
pool.releaseConnection("host", first.client);
pool.releaseConnection("host", second.client);
await expect(
pool.getConnection("host", () => deferredClient().promise),
).resolves.toBe(second.client);
pool.destroy();
});
it("discards a pending connection when its host pool is cleared", async () => {
const pool = new SSHConnectionPool();
const pending = deferredClient();
@@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import zlib from "node:zlib";
import * as tar from "tar";
import {
readArtifactFile,
readArtifactFiles,
readArtifactManifest,
sameCapabilities,
} from "../../plugins/artifact-manifest.js";
@@ -27,20 +28,42 @@ function pack(files: Record<string, string>, prefix = ""): Buffer {
}
}
describe("readArtifactFile", () => {
/** Two archives' entries back to back, so a name can appear twice. */
function concat(first: Buffer, second: Buffer): Buffer {
const entries = (buffer: Buffer) => {
const raw = zlib.gunzipSync(buffer);
// Drop the two zero blocks that end every tar.
return raw.subarray(0, raw.length - 1024);
};
return zlib.gzipSync(
Buffer.concat([entries(first), entries(second), Buffer.alloc(1024)]),
);
}
describe("readArtifactFiles", () => {
it("finds a file by name, with or without a ./ prefix", () => {
const plain = pack({ "manifest.json": "{}", "dist/a.js": "x" });
expect(readArtifactFile(plain, "dist/a.js")?.toString()).toBe("x");
expect(
readArtifactFiles(plain, "dist/a.js").map((b) => b.toString()),
).toEqual(["x"]);
const dotted = pack({ "manifest.json": '{"id":"a"}' }, "./");
expect(readArtifactFile(dotted, "manifest.json")?.toString()).toBe(
'{"id":"a"}',
expect(
readArtifactFiles(dotted, "manifest.json").map((b) => b.toString()),
).toEqual(['{"id":"a"}']);
});
it("returns nothing for a missing file", () => {
expect(readArtifactFiles(pack({ "a.txt": "a" }), "manifest.json")).toEqual(
[],
);
});
it("returns null for a missing file", () => {
expect(readArtifactFile(pack({ "a.txt": "a" }), "manifest.json")).toBe(
null,
);
it("resolves names that need a pax header", () => {
const long = `dist/${"x".repeat(150)}.js`;
const archive = pack({ "manifest.json": "{}", [long]: "x" });
expect(readArtifactFiles(archive, long).map((b) => b.toString())).toEqual([
"x",
]);
});
});
@@ -68,6 +91,18 @@ describe("readArtifactManifest", () => {
readArtifactManifest(pack({ "manifest.json": "[1]" })),
).toThrow(/not an object/);
});
it("refuses an archive with two manifests", () => {
const first = pack({ "manifest.json": '{"capabilities":[]}' });
const second = pack({ "manifest.json": '{"capabilities":["ssh"]}' });
expect(() => readArtifactManifest(concat(first, second))).toThrow(
/more than one manifest.json/,
);
const upper = pack({ "MANIFEST.JSON": '{"capabilities":["ssh"]}' });
expect(() => readArtifactManifest(concat(first, upper))).toThrow(
/more than one manifest.json/,
);
});
});
describe("sameCapabilities", () => {
@@ -207,6 +207,7 @@ vi.mock("../../hosts/delete-host.js", () => ({
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");
function manifest(capabilities: string[]) {
return {
@@ -436,6 +437,29 @@ describe("ctx.hosts", () => {
]);
});
it("publishes host.created for a host it creates", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
try {
await hosts.create({
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "password",
});
} finally {
off();
}
expect(seen).toEqual([{ hostId: 99, userId: "user-1" }]);
});
it("refuses a field the SDK does not take instead of dropping it", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
+335
View File
@@ -0,0 +1,335 @@
/**
* ctx.auth.issueUserToken mints a short-lived session core's own auth
* middleware accepts as the named user, under users:impersonate, audited and
* kept off account security routes. ctx.users.getLocale reads the user's UI
* language under users:read.
*/
import crypto from "crypto";
import jwt from "jsonwebtoken";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
grants: [] as string[],
users: new Map<string, { id: string; username: string; isAdmin: boolean }>(),
sessions: new Map<string, Record<string, unknown>>(),
languages: new Map<string, string | null>(),
audits: [] as Array<Record<string, unknown>>,
}));
vi.mock("../../database/db/index.js", () => ({
db: {},
getDb: () => ({}),
saveMemoryDatabaseToFile: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants.map((capability) => ({ pluginId, capability })),
}),
createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentSessionRepository: () => ({
create: async (session: Record<string, unknown>) => {
state.sessions.set(session.id as string, session);
return session;
},
findById: async (id: string) => state.sessions.get(id) ?? null,
touch: async () => {},
revoke: async (id: string) => state.sessions.delete(id),
}),
createCurrentUserRepository: () => ({
findById: async (id: string) => state.users.get(id) ?? null,
}),
createCurrentUserPreferenceRepository: () => ({
findByUserId: async (userId: string) =>
state.languages.has(userId)
? { userId, language: state.languages.get(userId) }
: null,
}),
createCurrentApiKeyRepository: () => ({}),
createCurrentTrustedDeviceRepository: () => ({}),
getCurrentSettingValue: () => null,
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
}));
vi.mock("../../utils/system-crypto.js", () => ({
SystemCrypto: {
getInstance: () => ({
getJWTSecret: async () => "a".repeat(64),
getEncryptionKey: async () => crypto.randomBytes(32),
}),
},
}));
vi.mock("../../utils/user-keys.js", () => ({
UserKeyManager: {
getInstance: () => ({
hasUserDEK: () => true,
tryGetUserDEK: () => null,
invalidate: vi.fn(),
}),
},
}));
vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({
adoptRecoveredDEK: vi.fn(async () => {}),
migratePasswordUserAtLogin: vi.fn(async () => true),
}));
const { createPluginContext, createPluginHandle } =
await import("../../plugins/ctx.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const { AuthManager } = await import("../../utils/auth-manager.js");
const { registerSecondFactor, resetAuthRegistryForTests } =
await import("../../auth/registry.js");
const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend");
const authManager = AuthManager.getInstance();
const authenticate = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
function contextFor(capabilities: string[], grants = capabilities) {
state.grants = grants;
const manifest = {
id: "ai",
name: "AI",
version: "1.0.0",
capabilities,
} as never;
return createPluginContext(manifest, createPluginHandle("ai", {} as never));
}
type Middleware = typeof authenticate;
async function call(middleware: Middleware, token: string, url: string) {
const req = {
cookies: {},
headers: { authorization: `Bearer ${token}` },
method: "POST",
originalUrl: url,
url,
} as unknown as Parameters<Middleware>[0] & { userId?: string };
const res = {
statusCode: 200,
body: null as unknown,
status(code: number) {
res.statusCode = code;
return res;
},
json(body: unknown) {
res.body = body;
return res;
},
clearCookie() {
return res;
},
};
let passed = false;
await middleware(req, res as never, () => {
passed = true;
});
return { passed, status: res.statusCode, body: res.body, userId: req.userId };
}
beforeEach(() => {
state.users = new Map([
["u1", { id: "u1", username: "alice", isAdmin: false }],
["admin", { id: "admin", username: "root", isAdmin: true }],
]);
state.sessions.clear();
state.languages.clear();
state.audits = [];
invalidatePluginPermissionCache();
resetAuthRegistryForTests();
});
afterEach(() => {
vi.useRealTimers();
});
describe("ctx.auth.issueUserToken", () => {
it("mints a session core's auth middleware accepts as that user", async () => {
const ctx = contextFor(["users:impersonate"]);
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
purpose: "nightly report",
});
const result = await call(authenticate, token, "/host/db/host");
expect(result.passed).toBe(true);
expect(result.userId).toBe("u1");
const session = [...state.sessions.values()][0];
expect(session).toMatchObject({
userId: "u1",
deviceType: "plugin",
deviceInfo: "ai: nightly report",
expiresAt,
});
const payload = jwt.decode(token) as { exp: number; iat: number };
expect(payload.exp - payload.iat).toBe(300);
});
it("keeps the user's own permissions", async () => {
const ctx = contextFor(["users:impersonate"]);
const user = await ctx.auth.issueUserToken("u1", { purpose: "check" });
const admin = await ctx.auth.issueUserToken("admin", { purpose: "check" });
const refused = await call(requireAdmin, user.token, "/users/list");
expect(refused.passed).toBe(false);
expect(refused.status).toBe(403);
expect((await call(requireAdmin, admin.token, "/users/list")).passed).toBe(
true,
);
});
it("caps the lifetime at 900 seconds", async () => {
const ctx = contextFor(["users:impersonate"]);
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
purpose: "long task",
ttlSeconds: 86_400,
});
const payload = jwt.decode(token) as { exp: number; iat: number };
expect(payload.exp - payload.iat).toBe(900);
expect(new Date(expiresAt).getTime() - Date.now()).toBeLessThanOrEqual(
900_000,
);
});
it("stops working when the session expires", async () => {
const ctx = contextFor(["users:impersonate"]);
const { token } = await ctx.auth.issueUserToken("u1", {
purpose: "short",
ttlSeconds: 60,
});
vi.useFakeTimers({ now: Date.now() + 61_000 });
const result = await call(authenticate, token, "/host/db/host");
expect(result.passed).toBe(false);
expect(result.status).toBe(401);
});
it("audits every issue with the plugin, the user and the purpose", async () => {
const ctx = contextFor(["users:impersonate"]);
await ctx.auth.issueUserToken("u1", { purpose: "nightly report" });
const entry = state.audits.find(
(audit) => audit.action === "plugin_user_token_issued",
);
expect(entry).toMatchObject({
username: "plugin:ai",
resourceType: "user",
resourceId: "u1",
success: true,
});
expect(JSON.parse(entry!.details as string)).toEqual({
pluginId: "ai",
purpose: "nightly report",
ttlSeconds: 300,
});
});
it("is refused without a granted users:impersonate", async () => {
for (const ctx of [
contextFor([]),
contextFor(["users:impersonate"], []),
contextFor(["users:read"]),
]) {
await expect(
ctx.auth.issueUserToken("u1", { purpose: "x" }),
).rejects.toBeInstanceOf(PluginCapabilityError);
}
expect(state.sessions.size).toBe(0);
expect(state.audits.every((audit) => audit.success === false)).toBe(true);
});
it("is refused for a user that does not exist, or without a purpose", async () => {
const ctx = contextFor(["users:impersonate"]);
await expect(
ctx.auth.issueUserToken("ghost", { purpose: "x" }),
).rejects.toThrow(/does not exist/);
await expect(
ctx.auth.issueUserToken("u1", { purpose: " " }),
).rejects.toThrow(/purpose/);
expect(state.sessions.size).toBe(0);
expect(
state.audits.filter(
(audit) =>
audit.action === "plugin_user_token_issued" && !audit.success,
),
).toHaveLength(2);
});
it("cannot change how the user signs in or mint lasting credentials", async () => {
registerSecondFactor({ id: "totp", pluginId: "totp" } as never);
const ctx = contextFor(["users:impersonate"]);
const { token } = await ctx.auth.issueUserToken("admin", {
purpose: "ops",
});
for (const url of [
"/users/change-password",
"/users/link-external-to-password",
"/users/admin/reset-password",
"/users/admin/u1/second-factors",
"/plugin-api/totp/setup",
]) {
const result = await call(authenticate, token, url);
expect(result.passed, url).toBe(false);
expect(result.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
}
const apiKeys = await call(requireAdmin, token, "/users/api-keys");
expect(apiKeys.passed).toBe(false);
expect(apiKeys.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
expect(
(await call(authenticate, token, "/plugin-api/other/run")).passed,
).toBe(true);
});
it("leaves a normal login session free to use those routes", async () => {
const token = await authManager.generateJWTToken("u1", {
deviceType: "web",
deviceInfo: "Chrome",
});
expect(
(await call(authenticate, token, "/users/change-password")).passed,
).toBe(true);
});
});
describe("ctx.users.getLocale", () => {
it("returns the stored language, or null", async () => {
state.languages.set("u1", "zh_CN");
state.languages.set("admin", null);
const ctx = contextFor(["users:read"]);
await expect(ctx.users.getLocale("u1")).resolves.toBe("zh_CN");
await expect(ctx.users.getLocale("admin")).resolves.toBeNull();
await expect(ctx.users.getLocale("nobody")).resolves.toBeNull();
});
it("is refused without a granted users:read", async () => {
state.languages.set("u1", "zh_CN");
await expect(
contextFor(["users:read"], []).users.getLocale("u1"),
).rejects.toBeInstanceOf(PluginCapabilityError);
await expect(
contextFor(["users:impersonate"]).users.getLocale("u1"),
).rejects.toBeInstanceOf(PluginCapabilityError);
});
});
+13
View File
@@ -461,6 +461,19 @@ describe("public route rate limit", () => {
expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false);
});
it("lets a plugin raise its own limit, capped, unless the admin set one", () => {
const hits = (limit: number, count: number) =>
Array.from({ length: count }, () =>
http.overPublicRateLimit("proxy", "ip", 0, limit),
).filter(Boolean).length;
expect(hits(500, 500)).toBe(0);
http.resetPublicRateLimits();
expect(hits(50_000, 10_001)).toBe(1);
http.resetPublicRateLimits();
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "2";
expect(hits(500, 3)).toBe(1);
});
it("starts a new window after a minute", () => {
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
+47 -2
View File
@@ -91,8 +91,8 @@ vi.mock("../../plugins/data.js", () => ({
forgetTables: () => {},
}));
vi.mock("../../plugins/permissions.js", () => ({
invalidatePluginPermissionCache: () => {},
vi.mock("../../plugins/permissions.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/permissions.js")>()),
}));
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
@@ -1084,3 +1084,48 @@ describe("more registries", () => {
).rejects.toMatchObject({ code: "KEY_CHANGED" });
});
});
it("requires exact consent before enabling a mirrored plugin with capabilities", async () => {
createFixturePlugin({
id: "manual-mirror",
root: path.join(data, "plugins"),
capabilities: ["kv:own"],
backendSource:
'export async function activate(ctx) { await ctx.kv.set("probe", "value"); }',
});
await boot();
const options = {
version: "1.0.0",
capabilities: ["kv:own"],
userId: "admin",
};
await expect(
manage.enablePluginWithConsent("manual-mirror", {
...options,
version: "0.9.0",
}),
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
await expect(
manage.enablePluginWithConsent("manual-mirror", {
...options,
capabilities: [],
}),
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
await expect(
manage.enablePluginWithConsent("manual-mirror", {
version: "1.0.0",
userId: "admin",
}),
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
expect(db.grants).toEqual([]);
expect(db.rows.get("manual-mirror")?.state).toBe("disabled");
const result = await manage.enablePluginWithConsent("manual-mirror", options);
expect(result.state).toBe("active");
expect(db.grants).toContainEqual(
expect.objectContaining({
pluginId: "manual-mirror",
capability: "kv:own",
grantedBy: "admin",
}),
);
});
@@ -256,4 +256,25 @@ describe("keys from a linked server", () => {
await registries.setSyncedRegistryKeys([]);
expect(getCustomPluginKeys()).toEqual([]);
});
it("marks them as synced unless an admin added the same key here", async () => {
const synced = newKey();
const shared = newKey();
serve(URL_A, { registry: "acme", publicKey: shared });
await registries.addRegistry(URL_A, pluginKeyId(shared));
await registries.setSyncedRegistryKeys([synced, shared]);
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(synced))).toBe(
false,
);
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(shared))).toBe(
true,
);
expect(registries.isLocallyTrustedPluginKey("ec40e29bcc4986b2")).toBe(true);
await registries.setSyncedRegistryKeys([]);
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(synced))).toBe(
false,
);
expect(registries.isLocallyTrustedPluginKey("unknown-key")).toBe(false);
});
});
@@ -75,7 +75,8 @@ const UNGATED: Record<string, string> = {
"schedule.every": "the plugin's own timers",
"schedule.after": "the plugin's own timers",
"audit.record": "writes under the runtime's actor, never a plugin value",
asUser: "always audited as plugin_as_user",
asUser:
"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",
"http.baseUrl": "reads the request's public origin",
@@ -308,4 +308,11 @@ describe("adoptTableSql", () => {
const joined = adoptTableSql("sqlite", "fleets", adopted).join("\n");
expect(splitStatements(joined)).toHaveLength(3);
});
it("drops a chunk that is only comments", () => {
expect(splitStatements("-- nothing to do here.\n")).toEqual([]);
expect(
splitStatements("/* note */;\n-- why\nSELECT 1;\n-- trailing"),
).toEqual(["-- why\nSELECT 1"]);
});
});
+237 -2
View File
@@ -11,6 +11,8 @@ const state = vi.hoisted(() => ({
fetch: vi.fn(),
loadBundled: vi.fn(),
syncedKeys: vi.fn(async (_keys: unknown[]) => {}),
syncedOnlyKeys: new Set<string>(),
grants: [] as string[],
}));
vi.mock("../../../utils/logger.js", () => ({
@@ -28,10 +30,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({
state: state.enabled.has(id) ? "enabled" : "disabled",
}),
}),
createCurrentPluginPermissionGrantRepository: () => ({}),
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async () => [],
grant: async ({ capability }: { capability: string }) =>
void state.grants.push(capability),
}),
}));
vi.mock("../../../plugins/registries.js", () => ({
setSyncedRegistryKeys: state.syncedKeys,
isLocallyTrustedPluginKey: (keyId: string) =>
keyId === "local-key" && !state.syncedOnlyKeys.has(keyId),
}));
vi.mock("../../../sync/client/http.js", () => ({
remoteFetch: state.fetch,
@@ -56,8 +64,10 @@ vi.mock("../../../plugins/index.js", () => ({
}),
}));
const { mirrorPlugins } = await import("../../../sync/client/plugins.js");
const { mirrorPlugins, isServerManaged } =
await import("../../../sync/client/plugins.js");
const { remoteJson } = await import("../../../sync/client/http.js");
const { sendCoreAlert } = await import("../../../notify/core-notify.js");
const bundled = {
id: "ssh-terminal",
@@ -92,6 +102,9 @@ beforeEach(() => {
state.install.mockReset();
state.fetch.mockReset();
state.loadBundled.mockReset();
state.syncedOnlyKeys = new Set();
state.grants = [];
vi.mocked(sendCoreAlert).mockClear();
state.loadBundled.mockImplementation(async (id: string) => {
const plugin = { ...bundled, id };
state.loaded.set(id, plugin);
@@ -171,4 +184,226 @@ describe("mirrorPlugins", () => {
expect(state.loaded.has("ssh-terminal")).toBe(true);
expect(state.enabled.has("ssh-terminal")).toBe(true);
});
describe("a user plugin the server has on", () => {
function serverRunsSignedBy(
keyId: string,
enabled = true,
version = keyId === "removed-key" ? "1.0.1" : "1.0.0",
) {
state.loaded.set("extra", {
id: "extra",
source: "user",
state: "loaded",
signedBy: keyId,
manifest: { id: "extra", version, capabilities: ["net"] },
});
vi.mocked(remoteJson).mockResolvedValue({
plugins: [
{
id: "extra",
name: "Extra",
version,
source: "user",
artifact: true,
enabled,
active: enabled,
desktop: "mirror",
syncEntities: [],
granted: ["net"],
},
],
});
}
it("turns it on when a key this desktop trusts signed it", async () => {
serverRunsSignedBy("local-key");
await mirrorPlugins({} as never);
expect(state.enabled.has("extra")).toBe(true);
expect(state.grants).toEqual(["net"]);
});
it.each(["server-key", "removed-key"])(
"leaves it off for untrusted key %s",
async (keyId) => {
serverRunsSignedBy(keyId);
state.syncedOnlyKeys.add("server-key");
await mirrorPlugins({} as never);
expect(state.enabled.has("extra")).toBe(false);
expect(state.grants).toEqual([]);
expect(sendCoreAlert).toHaveBeenCalledWith(
expect.objectContaining({ category: "termix.sync.unsigned_plugin" }),
);
},
);
it("leaves the server in charge of one a trusted key signed", () => {
serverRunsSignedBy("local-key");
expect(isServerManaged("extra")).toBe(true);
});
it.each(["server-key", "removed-key"])(
"lets this desktop's admin decide on one signed by %s",
async (keyId) => {
serverRunsSignedBy(keyId);
state.syncedOnlyKeys.add("server-key");
expect(isServerManaged("extra")).toBe(false);
state.enabled.add("extra");
await mirrorPlugins({} as never);
expect(state.enabled.has("extra")).toBe(true);
},
);
it("still switches an untrusted one off when the server does", async () => {
serverRunsSignedBy("server-key", false);
state.syncedOnlyKeys.add("server-key");
state.enabled.add("extra");
await mirrorPlugins({} as never);
expect(state.enabled.has("extra")).toBe(false);
});
it.each([
["server-key", false],
["local-key", true],
])(
"after an update signed by %s, keeps it on: %s",
async (keyId, stays) => {
serverRunsSignedBy(keyId, true, "2.0.0");
state.syncedOnlyKeys.add("server-key");
const reviewed = state.loaded.get("extra")!;
state.loaded.set("extra", {
...reviewed,
manifest: { id: "extra", version: "1.0.0", capabilities: ["net"] },
});
state.enabled.add("extra");
state.fetch.mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(4),
headers: new Headers(),
});
state.install.mockImplementation(async () => {
const plugin = { ...reviewed };
state.loaded.set("extra", plugin);
return plugin;
});
await mirrorPlugins({} as never);
expect(state.install).toHaveBeenCalled();
expect(state.enabled.has("extra")).toBe(stays);
expect(vi.mocked(sendCoreAlert).mock.calls.length > 0).toBe(!stays);
},
);
});
});
it("server bundled label must not enable a synced-only user plugin", async () => {
state.syncedOnlyKeys.add("server-key");
state.loaded.set("extra", {
id: "extra",
source: "user",
state: "loaded",
signedBy: "server-key",
manifest: { id: "extra", version: "1.0.0", capabilities: [] },
});
vi.mocked(remoteJson).mockResolvedValue({
plugins: [
{
id: "extra",
name: "Extra",
version: "1.0.0",
source: "bundled",
artifact: true,
enabled: true,
active: true,
desktop: "mirror",
syncEntities: [],
granted: [],
},
],
});
await mirrorPlugins({} as never);
expect(state.enabled.has("extra")).toBe(false);
});
it("remote bundled label cannot preserve approval across an untrusted update", async () => {
const reviewed = {
id: "extra",
source: "user",
state: "loaded",
signedBy: "server-key",
manifest: { id: "extra", version: "1.0.0", capabilities: [] },
};
state.loaded.set("extra", reviewed);
state.syncedOnlyKeys.add("server-key");
state.enabled.add("extra");
vi.mocked(remoteJson).mockResolvedValue({
plugins: [
{
id: "extra",
name: "Extra",
version: "2.0.0",
source: "bundled",
artifact: true,
enabled: true,
active: true,
desktop: "mirror",
syncEntities: [],
granted: [],
},
],
});
state.fetch.mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(4),
headers: new Headers(),
});
state.install.mockImplementation(async () => {
const plugin = {
...reviewed,
manifest: { ...reviewed.manifest, version: "2.0.0" },
};
state.loaded.set("extra", plugin);
return plugin;
});
await mirrorPlugins({} as never);
expect(state.install).toHaveBeenCalledOnce();
expect(state.enabled.has("extra")).toBe(false);
});
it("waits for an in-flight local plugin change before applying a remote update", async () => {
const { serialized } = await import("../../../plugins/manage.js");
let release!: () => void;
let entered!: () => void;
const ready = new Promise<void>((resolve) => {
entered = resolve;
});
const barrier = new Promise<void>((resolve) => {
release = resolve;
});
const localChange = serialized(async () => {
entered();
await barrier;
});
await ready;
serverHas("1.1.0");
state.fetch.mockResolvedValue({ ok: false, status: 502 });
vi.mocked(remoteJson).mockClear();
const syncing = mirrorPlugins({} as never);
try {
await vi.waitFor(() => expect(remoteJson).toHaveBeenCalled());
expect(state.fetch).not.toHaveBeenCalled();
} finally {
release();
}
await localChange;
await syncing;
expect(state.fetch).toHaveBeenCalledOnce();
});
@@ -0,0 +1,75 @@
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();
});
+246 -3
View File
@@ -14,6 +14,8 @@ 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";
@@ -38,13 +40,18 @@ vi.mock("../../utils/system-crypto.js", () => ({
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 3) },
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 () => ({ hasAccess: false }),
canAccessHost: async (_userId: string, hostId: number) => ({
hasAccess: state.sharedHostIds.has(hostId),
}),
getUserPermissions: async () => [],
}),
},
@@ -117,6 +124,7 @@ vi.mock("../../sync/client/http.js", async (importOriginal) => {
path: string,
init: { method?: string; body?: unknown } = {},
) => {
state.paths.push(path);
const previous = state.current;
state.current = state.server;
try {
@@ -147,11 +155,15 @@ 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, resetFeed } = await import("../../sync/server/feed.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" }));
@@ -239,6 +251,8 @@ 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")];
@@ -307,6 +321,60 @@ describe("linking a desktop", () => {
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", () => {
@@ -480,6 +548,181 @@ describe("after linking", () => {
});
});
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;
+96 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const createMock = vi.fn().mockResolvedValue(undefined);
@@ -60,6 +60,101 @@ describe("logAudit", () => {
});
});
describe("logAudit plugin routine coalescing", () => {
const WINDOW_MS = 10 * 60 * 1000;
let run = 0;
let details = "";
const routine = (overrides: Record<string, unknown> = {}) =>
logAudit({
userId: null,
username: "plugin:automations",
action: "plugin_as_user",
resourceType: "plugin",
resourceId: "automations",
details,
success: true,
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
createMock.mockResolvedValue(undefined);
vi.useFakeTimers();
// The coalescing memory is module state: a fresh key per test keeps them
// independent.
details = `automations ran background work as user-${++run}`;
});
afterEach(() => {
vi.useRealTimers();
});
it("writes one row for identical successful plugin_as_user repeats in the window", async () => {
await routine();
vi.advanceTimersByTime(WINDOW_MS - 1);
await routine();
await routine();
expect(createMock).toHaveBeenCalledOnce();
});
it("writes one row for identical successful plugin service calls in the window", async () => {
const service = {
action: "plugin_service_host_metrics_open",
resourceId: "host-metrics",
};
await routine(service);
await routine(service);
expect(createMock).toHaveBeenCalledOnce();
});
it("writes again once the window has passed", async () => {
await routine();
vi.advanceTimersByTime(WINDOW_MS);
await routine();
expect(createMock).toHaveBeenCalledTimes(2);
});
it("always writes failures", async () => {
await routine();
await routine({ success: false, errorMessage: "boom" });
await routine({ success: false, errorMessage: "boom" });
expect(createMock).toHaveBeenCalledTimes(3);
});
it("never coalesces non-plugin or non-routine actions", async () => {
const login = {
userId: "user-1",
username: "alice",
action: "login",
resourceType: "user",
};
await routine(login);
await routine(login);
const secretRead = { action: "plugin_secret_shared_read" };
await routine(secretRead);
await routine(secretRead);
const notPlugin = { username: "alice" };
await routine(notPlugin);
await routine(notPlugin);
expect(createMock).toHaveBeenCalledTimes(6);
});
it("does not coalesce entries with different details or resourceId", async () => {
await routine();
await routine({ details: `${details} (other)` });
await routine({ resourceId: "other-plugin" });
await routine({ userId: "user-2" });
expect(createMock).toHaveBeenCalledTimes(4);
});
});
describe("getRequestMeta", () => {
it("uses the proxy-validated Express IP", () => {
const req = {
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
getTrustedProxyAuthConfig,
getTrustedProxySourceAddress,
isTrustedProxyAddress,
parseTrustedProxyRoleMap,
resolveTrustedProxyRoles,
@@ -28,6 +29,35 @@ describe("trusted proxy authentication config", () => {
expect(isTrustedProxyAddress("2001:db8::5", trusted)).toBe(true);
});
it("judges requests relayed by the bundled nginx by nginx's own peer", () => {
const viaNginx = (peer?: string) => ({
headers: peer ? { "x-termix-proxy-peer": peer } : {},
socket: { remoteAddress: "::ffff:127.0.0.1" },
});
expect(getTrustedProxySourceAddress(viaNginx("172.20.0.10"))).toBe(
"172.20.0.10",
);
// A client reaching nginx directly is reported as itself, so trusting
// the auth proxy no longer means trusting nginx on loopback.
expect(
isTrustedProxyAddress(
getTrustedProxySourceAddress(viaNginx("203.0.113.7")),
["127.0.0.1"],
),
).toBe(false);
// A local reverse proxy that does not set the header keeps working.
expect(getTrustedProxySourceAddress(viaNginx())).toBe("::ffff:127.0.0.1");
});
it("ignores the peer header on connections that are not from loopback", () => {
expect(
getTrustedProxySourceAddress({
headers: { "x-termix-proxy-peer": "172.20.0.10" },
socket: { remoteAddress: "198.51.100.9" },
}),
).toBe("198.51.100.9");
});
it("fails closed when a supplied external role is not mapped", () => {
const roleMap = parseTrustedProxyRoleMap(
JSON.stringify({ operators: ["operator"], viewers: "readonly" }),