* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

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

* chore: skip crowdin pre-translate in release for now

* chore: switch to calver, bump version to 26.10.0

* fix(sso): send the PKCE verifier for GitHub login (#1534)

* fix(remote-desktop): clip cursor overflow without disabling zoom (#1535)

* test(remote-desktop): preserve sessions beyond one hour (#1536)

* fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests

* fix(status): skip TCP probes while host sessions are active (#1538)

* fix(database): batch session activity persistence (#1539)

* docs(api): describe cookie and API key authentication (#1540)

* fix(snippets): repair missing note column on SQLite upgrades (#1541)

* fix(docker): retain stored SSH credential association (#1543)

* fix(file-manager): render transfer toasts without plugin hooks (#1546)

* feat(hosts): keep compact row actions inline (#1547)

* fix(auth): allow retrying unavailable second-factor interfaces (#1549)

* fix(auth): reject unresolved legacy identity provisioning (#1550)

* fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551)

* fix(tmux): pass full session info to the terminal's session picker (#1552)

The tmux.sessions service reduced detected sessions to bare names, but the
picker reads session.name, so every entry rendered blank and selecting one
sent an empty name, which created a new session instead of attaching.

* fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)

The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".

* fix(database): batch database saves for bulk host writes (#1554)

* fix(database): yield to the event loop between database saves

Each SQLite save serializes and encrypts the whole database into new
buffers, which V8 only releases once the event loop turns. Boot
migrations and bulk host import write plugin settings per host and per
key in an awaited loop, so hundreds of full copies piled up and the
container ran out of memory.

* fix(database): save once per bulk host write instead of once per row

On SQLite every repository write force-saves the whole database. Boot
plugin data migrations, host defaults materialization and the bulk
host routes write one row per host per setting, so they serialized and
encrypted the full database hundreds of times in a row.

DatabaseSaveTrigger.batched wraps a function so force saves made while
it runs collapse into a single save when it finishes. Nested scopes fold
into the outer one, and work that outlives its scope saves normally.

* feat(hosts): add instance-wide predefined tag suggestions (#1548)

* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes

* test(database): advance fake timers past the save yield (#1555)

* fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles

* fix: 2.8 oidc accounts unable to sign in after upgrading (#1381)

* fix: plugins losing the saved ssh login when copying a host (#1391)

* fix: fleets, proxmox and automations not getting the host sudo password

* fix: host imports running a defaults pass and metrics restart per host (#1384)

* fix: high cpu from status probes and full database saves on every sample (#1300)

* fix: user data export freezing the server (#1393)

* fix: op:// secret references rejected as ssh keys on credentials (#1394)

* fix: slow file deletes from the trash lookups on every delete (#1390)

* fix: add openapi docs and error handling to host tag routes

* test: compare download stream buffers directly so it stops timing out

* chore: drop em dash from host row comment

* chore: update release notes for 2.9.1

* feat(ai): add remote coding agent sessions for SSH hosts (#1545)

* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL

* test: run the posix-only ai agent tests on linux only

* fix: restore space to add host tags and redesign predefined tags editor

* fix: host key silently accepted when the host is missing from the database (#1397)

* fix: clearer host login failed status label and fix its translations (#1396)

* chore: add host key and status label fixes to release notes

* fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

* chore: increment ver

* fix(audit): store plugin entries with no acting user as a null user_id (#1556)

Plugin audit entries written outside a request used the literal "system"
as user_id. That column references users.id, so the insert was refused
(Postgres logs it as an FK violation) and the entry was silently dropped.
Write null instead and keep "system" / plugin:<id> in username.

* chore: add sso/ldap dialog and audit log fixes to release notes

* fix: tunnels and host settings missing from shared hosts on desktop

* fix: remote desktop logins missing from shared hosts on desktop

* fix: simplify host status to online/offline and keep it live without a refresh

* chore: sync Crowdin translations for 2.9.1

* fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi

* chore: rework plugin readmes, json changelogs and standalone repo setup

* chore: move plugins to their own repos and drop the plugins folder

* chore(deps-dev): bump globals (#1560)

Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals).


Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: drop the 2.8 columns, moves and aliases kept for a downgrade

* chore: remove the 2.9 finish list

* feat: port the demo redesign with Manage and Settings tabs

* chore: pin the plugin signing key and use TERMIX_PAT in workflows

* chore: rename the plugin sdk to @termix-ssh/plugin-sdk

* feat: add a publish workflow for the plugin sdk

* chore: cleanup

* fix: harden plugin sockets, public routes, prebuilt binaries and login fallback

* chore: add a 2.9.1 upgrade check for postgres and mysql

* fix: stop full database rewrites on every plugin db call

* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows

* feat: pin the official plugins in the docker image

* feat: implement plugin management UI

* feat: shared list kit, settings search, rail order and ui consistency fixes

* chore: run all workflows on blacksmith runners

* chore: split dashboard quick actions into quarters and drop host status row lines

* fix: open plain settings from the profile button, navigation only from the rail menu

* feat: consent check, developer mode file installs, install counts and issue links for plugins

* feat: row detail options for hosts and credentials, open manage from sidebar header

* fix: unify connection failure screens and rebuild stale local plugins

* feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

* chore: clean up npm scripts, make npm run dev the full runner and drop concurrently

* fix: settle quick connect panel loads in tests so they stop flaking

* fix: stop the dev runner rebuilding plugins when windows reports file reads as changes

* chore: update the plugin sdk ui export snapshot

* feat: full screen onboarding with a plugin picker and per step versions

* feat: changelog.md release notes, v tags, plugin release notes and videos

* chore: add temp changelog

* chore: release the plugin sdk as 1.0.1

* feat: show only the latest plugin release notes and cap long ones

* chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub

* chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN

* chore: renew mac app store provisioning profile

* chore: drop docs job from release workflow

* chore: run sdk publish on github hosted runner

* chore: remove dependabot

* chore: general file cleanup

* chore: rewrite readme and taglines for plugin-based termix

* chore: drop optional from ai plugin row in readme

* feat: per-repo issue forms and prefilled bug reports instead of the support repo

* feat: add report a bug link to the your account settings section

* fix: open settings goes to the plugin's page and hides when it has none

* feat: show plugin features from the manifest on the plugin page

* fix: retry failed or blocked plugins on restart instead of leaving them disabled

* fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling

* feat: request a feature link for plugins and use the bug form for unverified plugins

* feat: beta channels for core and plugins with one stable/beta release workflow

* fix(sync): resolve shared hosts for connect-only recipients (#1568)

* fix(ssh): accept full passwords in keyboard-interactive prompts (#1569)

* fix(ssh): normalize PKCS8 private keys before connecting (#1570)

* fix(sidebar): reclaim space from hidden folder actions (#1571)

* fix(hosts): display protocol credential usernames for non-SSH hosts (#1572)

* fix: ask for a push/TOTP menu choice as text instead of a push confirm

* fix: keep client tunnels open when one of their connections fails (Support#1417)

* fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420)

* fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419)

* fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418)

* fix: refresh cached plugin translations when only a translation changes (Support#1411)

* fix: import sshpk as a default so the backend starts under ESM

* feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2

* fix: drop the ./ from the plugin sdk bin path so npm stops warning

* chore: name core and sdk releases Termix vX and Plugin SDK vX

* fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g)

* fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)

* fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw)

* fix: refuse sessionless tokens and keep the secrets file owner only

* feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm)

* chore: standardize repo setup files

* chore: remove unused knip config

* chore: add LICENSE and use Node 24 from .nvmrc

* chore: update translation guidance, only en.json needs editing

* feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

* fix: header separators render as borders and center the status divider

* fix: lighter confirm prompt overlay to stop lag when it opens

* fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4

* fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK

* fix: keep plugin tailwind variants in core's layer so overrides keep their order

* fix: manage tab refetches before giving up on a host or credential it cannot find

* fix: tab buttons like share show as soon as the terminal is ready

* fix: folder rows no longer nest action buttons inside a button

* fix: repin remote-desktop after its 1.0.0 re-release

* chore: repin bundled plugins after the 1.0.0 re-release

* fix: recent activity rows use the same separator as host status

* feat: upload banner

* fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24

* fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks

* fix: bundled plugins path for source runs and backend cleanup

* fix: load plugin strings on guest pages, plugin wording and drop unused keys

* fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms

* docs: 26.10.0 changelog

* fix: current version in the version route example

* chore: stop pushing the release- docker tag

* feat: remove the donation reminder and its column

* docs: note the removed donation reminder

* fix: link server wizard always opens inline, onboarding card grows for it

* feat: plugin text columns are longtext on mysql so they are not capped at 64KB

* fix: linked desktops keep a plugin when its update from the server fails

* chore: default mobile download links to v tags

* fix: sso only accounts cannot sign in with a passkey or other local method

* feat: note in the plugins tab that community plugins are coming

* fix: plugin validate accepts beta versions

* chore: pin bundled plugins rebuilt on sdk 1.0.5

* fix: skip locale fetches for disabled plugins

* fix: show translated names for the built-in admin and user roles

* fix: show host keys as SHA256 fingerprints like ssh-keygen

* fix: host editor scrolls to a newly enabled protocol section

* fix: pass the host to dashboard host row plugins

* fix: plugin cards show dividers between author, version and category

* fix: refuse plugin archives with paths outside the plugin folder

* fix: theme picker shows one dropdown arrow

* fix: read activity and audit times as UTC so they show in local time

* fix: show the real error for a wrong password or code on plugin sign in

* fix: explain that external sign in needs Auto-create external accounts

* fix: clear leftover plugin unpack folders at startup

* fix: shared session links open for guests again

* fix: share panels no longer list yourself as a share target

* fix: a host update without an auth type keeps the saved password

* fix: guest pages get the tab ids a guest plugin registers

* fix: refuse a 2.8 database, which already had a plugins table

* fix: dashboard cards fit on a phone

* fix: desktop setup no longer asks to secure the local account

* fix: the desktop app keeps the desktop layout in a narrow window

* fix: the desktop app shows the rail and no phone bar in a narrow window

* fix: name every sync type in the Sync panel and pluralize the link prompt

* chore: update the sdk ui snapshot for the host key dialog

* fix: turning a plugin back on restarts the plugins it turned off

* fix: quick connect asks to trust the host key

* fix: revoking a manager also removes the shares they handed out

* fix: database export works for users with TOTP on (#1426)

* fix: rdp and vnc logins use the folder credential when none is picked (#1429)

* fix: imported hosts get status checks without a restart

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: re-pin bundled plugins after search bar fix

* chore: update app icon to smaller version

* chore: round the readme logo, keep square source for app icons

* feat: grid columns and table view in the plugins tab

* fix: match the host key copy button to the fingerprint height

* fix: use brand color for toggles in sync settings

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: web terminals failing auth behind a proxy that rewrites Host (Support#651)

* fix: re-pin snippets and termix-identity after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* chore: remove Crowdin from the release workflow

* chore: remove crowdin config

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 14:58:27 -05:00
1 parent a075ff4808
commit 0149260926
3656 files changed
+116210 -460999

No files matched your search

@@ -10,8 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
*
* The Drizzle schema said so, the repository was written against it, but the
* runtime bootstrap still created `user_id TEXT NOT NULL`. A second, corrected
* `CREATE TABLE IF NOT EXISTS` further down was a no-op — the table already
* existed — so every fresh install got the old constraint and every user
* `CREATE TABLE IF NOT EXISTS` further down was a no-op, the table already
* existed, so every fresh install got the old constraint and every user
* deletion (including the OIDC account-link cleanup) failed once the account
* had logged in at least once.
*/
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a
* number of tables that the primary bootstrap already creates. The probe never
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and one
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran, and one
* of those unreachable copies had drifted away from the real definition
* (`sessions` had lost `ON DELETE CASCADE`).
*
@@ -61,7 +61,7 @@ describe("bootstrap creates the tables the removed probes covered", () => {
});
it("creates them on a database old enough to predate them", async () => {
// A database with users and hosts but none of the tables above — the
// A database with users and hosts but none of the tables above, the
// upgrade path the deleted probes appeared to be protecting.
const seed = new Database(":memory:");
seed.exec(`
@@ -59,7 +59,7 @@ describe("needsExplicitPersist", () => {
/**
* schema.pg.ts and schema.mysql.ts are generated from schema.ts. These check the
* generated output is usable rather than merely syntactically valid — the
* generated output is usable rather than merely syntactically valid, the
* repository layer's correctness rests on all three behaving the same way.
*/
describe("generated schemas", () => {
@@ -113,8 +113,8 @@ describe("generated schemas", () => {
expect(auditFks).toHaveLength(1);
expect(auditFks[0].onDelete).toBe("set null");
const folderFks = read(schema.sshFolders).foreignKeys;
expect(folderFks.map((fk) => fk.onDelete).sort()).toEqual([
const hostFks = read(schema.hosts).foreignKeys;
expect([...new Set(hostFks.map((fk) => fk.onDelete))].sort()).toEqual([
"cascade",
"set null",
]);
@@ -133,7 +133,7 @@ describe("generated schemas", () => {
* SQLite builds its indexes at runtime from PERFORMANCE_INDEXES; Postgres and
* MySQL only ever get what the migrations declare, which comes from schema.ts.
* Anything listed in one and missing from the other is an index the engines
* chosen for scale silently do without — which is how 31 of them went missing.
* chosen for scale silently do without, which is how 31 of them went missing.
*/
it("declares every performance index in schema.ts", () => {
// Both are the leading column of an existing composite unique index, which
@@ -37,6 +37,18 @@ describe("RDP domain across database boots", () => {
"utf8",
),
);
// 2.9 has opened it, so it has the table 2.9 added.
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
);
`);
seed.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('owner', 'alice', 'hash');
@@ -0,0 +1,208 @@
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";
/**
* A database 2.9 left behind still has the 2.8 columns it kept for a
* downgrade. Opening it in 26.10.0 drops them, keeps what still matters
* (a sudo password kept in terminal_config, an SSO sign-in, the hosts and
* folders themselves) and removes the settings rows the plugins copied.
*/
describe("dropping the columns 2.9.0 kept", () => {
let dataDir: string;
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-retire-"));
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 write29Database(): void {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'available',
source TEXT NOT NULL DEFAULT 'community',
registry_id TEXT,
state TEXT NOT NULL DEFAULT 'disabled',
last_error TEXT,
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
auto_update INTEGER NOT NULL DEFAULT 0,
manifest_json TEXT NOT NULL
);
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
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_oidc INTEGER NOT NULL DEFAULT 0,
oidc_identifier TEXT,
client_secret TEXT
);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
auth_type TEXT NOT NULL,
username TEXT
);
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
auth_type TEXT NOT NULL DEFAULT 'password',
sudo_password TEXT,
terminal_config TEXT,
quick_actions TEXT,
tunnel_connections TEXT,
enable_docker INTEGER NOT NULL DEFAULT 0,
stats_config TEXT,
rdp_user TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ssh_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE user_preferences (
user_id TEXT PRIMARY KEY,
theme TEXT,
terminal_defaults TEXT,
rdp_defaults TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash, is_oidc, oidc_identifier)
VALUES ('u1', 'alice', 'hash', 1, 'sub-1');
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
VALUES (5, 'u1', 'cred', 'password');
INSERT INTO ssh_data (id, user_id, name, ip, port, username,
terminal_config, tunnel_connections, enable_docker, rdp_user)
VALUES
(1, 'u1', 'web', '10.0.0.1', 22, '$oidc.preferred_username',
'{"sudoPassword":"s3cret","theme":"nord"}', '[]', 1, 'admin'),
(2, 'u1', 'db', '10.0.0.2', 22, 'root', NULL, NULL, 0, NULL);
INSERT INTO ssh_folders (id, user_id, name, color, credential_id)
VALUES (3, 'u1', 'prod', '#fff', 5);
INSERT INTO user_preferences (user_id, theme, terminal_defaults, rdp_defaults)
VALUES ('u1', 'dark', '{}', '{}');
INSERT INTO settings (key, value) VALUES
('guac_url', 'guacd:4822'),
('analytics_enabled', 'true'),
('allow_registration', 'false');
`);
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
seed.close();
}
const columns = (sqlite: Database.Database, table: string) =>
(sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
name: string;
}>).map((col) => col.name);
it("drops the columns and rows and keeps the data that still matters", async () => {
write29Database();
const db = await import("../../../database/db/index.js");
await db.initializeDatabase();
const sqlite = db.getSqlite();
expect(columns(sqlite, "users")).not.toContain("oidc_identifier");
expect(columns(sqlite, "users")).not.toContain("is_oidc");
expect(columns(sqlite, "ssh_data")).not.toEqual(
expect.arrayContaining(["terminal_config"]),
);
for (const gone of [
"terminal_config",
"quick_actions",
"tunnel_connections",
"enable_docker",
"stats_config",
]) {
expect(columns(sqlite, "ssh_data")).not.toContain(gone);
}
// A login moved only once its owner signs in stays until then.
expect(columns(sqlite, "ssh_data")).toContain("rdp_user");
expect(columns(sqlite, "ssh_folders")).not.toContain("credential_id");
expect(columns(sqlite, "user_preferences")).not.toContain("rdp_defaults");
expect(columns(sqlite, "user_preferences")).not.toContain(
"terminal_defaults",
);
const hosts = sqlite
.prepare("SELECT id, username, sudo_password FROM ssh_data ORDER BY id")
.all();
expect(hosts).toEqual([
{ id: 1, username: "$external.username", sudo_password: "s3cret" },
{ id: 2, username: "root", sudo_password: null },
]);
expect(
sqlite.prepare("SELECT name, color FROM ssh_folders").all(),
).toEqual([{ name: "prod", color: "#fff" }]);
expect(
sqlite.prepare("SELECT theme FROM user_preferences").get(),
).toEqual({ theme: "dark" });
const keys = (
sqlite.prepare("SELECT key FROM settings").all() as Array<{
key: string;
}>
).map((row) => row.key);
expect(keys).toContain("allow_registration");
expect(keys).not.toContain("guac_url");
expect(keys).not.toContain("analytics_enabled");
});
it("refuses a 2.8 database", async () => {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL,
password_hash TEXT NOT NULL);
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT NOT NULL);
`);
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
seed.close();
const db = await import("../../../database/db/index.js");
await expect(db.initializeDatabase()).rejects.toThrow(/2\.8 or older/);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
legacySudoMoves,
legacySudoPassword,
} from "../../../database/db/retired-columns.js";
describe("legacySudoPassword", () => {
it("reads the sudo password out of a terminal_config value", () => {
expect(legacySudoPassword('{"sudoPassword":"s3cret"}')).toBe("s3cret");
expect(legacySudoPassword({ sudoPassword: "s3cret" })).toBe("s3cret");
expect(legacySudoPassword(JSON.stringify('{"sudoPassword":"x"}'))).toBe(
"x",
);
});
it("finds nothing in anything else", () => {
expect(legacySudoPassword(null)).toBeNull();
expect(legacySudoPassword("{")).toBeNull();
expect(legacySudoPassword('{"sudoPassword":""}')).toBeNull();
expect(legacySudoPassword("[1]")).toBeNull();
});
});
describe("legacySudoMoves", () => {
it("moves only hosts with no sudo password of their own", () => {
expect(
legacySudoMoves([
{ id: 1, terminal_config: '{"sudoPassword":"a"}', sudo_password: null },
{ id: 2, terminal_config: '{"sudoPassword":"b"}', sudo_password: "own" },
{ id: 3, terminal_config: '{"theme":"nord"}', sudo_password: null },
]),
).toEqual([{ id: 1, sudoPassword: "a" }]);
});
});
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Sharing a host used to hand the owner's SSH authentication to the recipient
* unconditionally. 2.6.1 put that behind `ssh_data.share_ssh_auth`, added as
* `NOT NULL DEFAULT 0` — so every host shared before the upgrade silently
* `NOT NULL DEFAULT 0`, so every host shared before the upgrade silently
* stopped supplying credentials, and recipients hit "No valid authentication
* method provided" on hosts that had worked the day before.
*
@@ -33,10 +33,37 @@ describe("share_ssh_auth backfill", () => {
fs.rmSync(dataDir, { recursive: true, force: true });
});
/** A 2.6.0 database: hosts and shares exist, the column does not. */
/**
* Hosts and shares exist, the column does not. The plugin tables are there
* because 26.10.0 only opens a database 2.9 has already upgraded.
*/
function writePreUpgradeDatabase(): void {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'available',
source TEXT NOT NULL DEFAULT 'community',
registry_id TEXT,
state TEXT NOT NULL DEFAULT 'disabled',
last_error TEXT,
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
auto_update INTEGER NOT NULL DEFAULT 0,
manifest_json TEXT NOT NULL
);
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
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* `ssh_credentials.username` became nullable when key-only credentials landed,
* and databases created before that are rebuilt on startup to drop the
* constraint — SQLite cannot ALTER a column.
* constraint, SQLite cannot ALTER a column.
*
* The rebuild restated the table's columns as a literal, then copied rows with
* `INSERT INTO temp SELECT <every live column>`. The table has gained columns
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
assertNotPre29Database,
PRE_29_DATABASE_ERROR,
} from "../../../database/db/upgrade-guard.js";
const tables = (...names: string[]) => (name: string) => names.includes(name);
describe("assertNotPre29Database", () => {
it("refuses a 2.8 database, which has hosts and plugins but no plugin_migrations", async () => {
await expect(
assertNotPre29Database(tables("ssh_data", "plugins")),
).rejects.toThrow(PRE_29_DATABASE_ERROR);
});
it("opens a database 2.9 has upgraded", async () => {
await expect(
assertNotPre29Database(tables("ssh_data", "plugins", "plugin_migrations")),
).resolves.toBeUndefined();
});
it("opens a new database", async () => {
await expect(assertNotPre29Database(tables())).resolves.toBeUndefined();
});
});
describe("a real 2.8.0 schema", () => {
it("is refused", async () => {
const { default: Database } = await import("better-sqlite3");
const fs = await import("node:fs");
const db = new Database(":memory:");
db.exec(
fs.readFileSync(
new URL("../../fixtures/sqlite-2.8-schema.sql", import.meta.url),
"utf8",
),
);
const has = (name: string) =>
!!db
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
await expect(assertNotPre29Database(has)).rejects.toThrow(
PRE_29_DATABASE_ERROR,
);
db.close();
});
});
@@ -9,7 +9,10 @@
import { asc } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./repositories/test-support.js";
import {
TestSqliteDatabase,
testDialect,
} from "./repositories/test-support.js";
import { HostRepository } from "../../database/repositories/host-repository.js";
import { UserRepository } from "../../database/repositories/user-repository.js";
import type { DatabaseContext } from "../../database/repositories/database-context.js";
@@ -48,13 +51,16 @@ async function seed(): Promise<DatabaseContext> {
return context;
}
// key is reserved in MySQL
const KEY_COLUMN = testDialect() === "mysql" ? "`key`" : "key";
async function addSetting(
scope: string,
scopeId: string | null,
key = "k",
): Promise<void> {
await adapter!.exec(
`INSERT INTO plugin_settings (plugin_id, scope, scope_id, key, value)
`INSERT INTO plugin_settings (plugin_id, scope, scope_id, ${KEY_COLUMN}, value)
VALUES ('sample', '${scope}', ${scopeId === null ? "NULL" : `'${scopeId}'`}, '${key}', '"v"')`,
);
}
@@ -89,7 +89,6 @@ describe("HostFolderRepository", () => {
"prod",
"#abcdef",
"folder",
undefined,
"2026-02-01T00:00:00.000Z",
),
).resolves.toMatchObject({
@@ -102,7 +101,6 @@ describe("HostFolderRepository", () => {
"new",
null,
null,
null,
"2026-03-01T00:00:00.000Z",
),
).resolves.toMatchObject({
@@ -112,28 +110,6 @@ describe("HostFolderRepository", () => {
expect(writes).toBe(2);
});
it("assigns a credential to a folder and resolves it for nested paths", async () => {
const { repository } = await createRepository();
await expect(
repository.upsertMetadata(
"user-1",
"prod",
undefined,
undefined,
1,
"2026-02-01T00:00:00.000Z",
),
).resolves.toMatchObject({
created: false,
folder: { credentialId: 1 },
});
const folders = await repository.listFolders("user-1");
const prodFolder = folders.find((f) => f.name === "prod");
expect(prodFolder?.credentialId).toBe(1);
});
it("lists and deletes hosts and folder records in a folder tree", async () => {
let writes = 0;
const { repository } = await createRepository(() => {
@@ -44,11 +44,6 @@ describe("HostResolutionRepository", () => {
(1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password', 7, '[{"autoStart":true}]'),
(2, 'user-1', 'db', '10.0.0.2', 22, 'admin', 'none', NULL, NULL),
(3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'none', NULL, '[{"autoStart":false}]');
INSERT INTO ssh_folders (user_id, name, credential_id)
VALUES
('user-1', 'switches', 7),
('user-1', 'switches / floor1', NULL),
('user-1', 'no-cred', NULL);
INSERT INTO host_access (
host_id, user_id, granted_by, permission_level
)
@@ -330,26 +325,6 @@ describe("HostResolutionRepository", () => {
).resolves.toBeNull();
});
it("resolves a folder's assigned credential, walking up to parent folders", async () => {
const repository = await createRepository();
await expect(
repository.findFolderCredentialId("user-1", "switches"),
).resolves.toBe(7);
await expect(
repository.findFolderCredentialId("user-1", "switches / floor1"),
).resolves.toBe(7);
await expect(
repository.findFolderCredentialId("user-1", "no-cred"),
).resolves.toBeNull();
await expect(
repository.findFolderCredentialId("user-1", "unknown"),
).resolves.toBeNull();
await expect(
repository.findFolderCredentialId("user-1", ""),
).resolves.toBeNull();
});
describe("listCredentialsByIdsForUser", () => {
it("returns the owner's credentials keyed by id", async () => {
const repository = await createRepository();
@@ -17,7 +17,7 @@ describe("rowsAffected", () => {
});
it("reads changes from a better-sqlite3 write result", () => {
// The shape of a write with no .returning() attached — verified against
// The shape of a write with no .returning() attached, verified against
// the driver, not assumed.
expect(rowsAffected({ changes: 1, lastInsertRowid: 7 })).toBe(1);
expect(rowsAffected({ changes: 0, lastInsertRowid: 7 })).toBe(0);
@@ -81,7 +81,7 @@ describe("supportsReturning", () => {
expect(supportsReturning("sqlite")).toBe(true);
expect(supportsReturning("postgres")).toBe(true);
// No RETURNING clause in MySQL, and drizzle's mysql-core does not expose
// the method — call sites that need rows back must read first.
// the method, call sites that need rows back must read first.
expect(supportsReturning("mysql")).toBe(false);
});
});
@@ -19,11 +19,11 @@ describe("RbacAccessRepository", () => {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
INSERT INTO users (id, username, password_hash, is_admin)
VALUES
('admin', 'admin', 'hash', 1, 0),
('user-1', 'alice', 'hash', 0, 0),
('owner-1', 'owner', 'hash', 0, 0);
('admin', 'admin', 'hash', 1),
('user-1', 'alice', 'hash', 0),
('owner-1', 'owner', 'hash', 0);
INSERT INTO roles (id, name, display_name, is_system)
VALUES (7, 'ops', 'Operations', 0);
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
@@ -11,7 +11,7 @@ import type { DatabaseDialect } from "../../../database/db/dialect.js";
* The MySQL path cannot be exercised against a real engine here, and its whole
* correctness is an ordering property: an update must be read AFTER the write,
* a delete BEFORE it. Get either backwards and the rows describe the wrong
* state — silently, with no error anywhere.
* state, silently, with no error anywhere.
*
* So the drizzle handle is stubbed and the order of calls is recorded.
*/
@@ -169,7 +169,7 @@ describe("deleteReturning", () => {
await deleteReturning(context, {} as never, where);
// Without this, a concurrent write between them makes the returned rows
// describe a state that never existed — and with a pool the second
// describe a state that never existed, and with a pool the second
// statement need not even reach the same connection.
expect(calls[0]).toBe("begin");
expect(calls[calls.length - 1]).toBe("commit");
@@ -18,8 +18,8 @@ describe("RoleRepository", () => {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'user', 'hash', 0, 0);
INSERT INTO users (id, username, password_hash, is_admin)
VALUES ('admin', 'admin', 'hash', 1), ('user-1', 'user', 'hash', 0);
`);
return new RoleRepository(context, onWrite);
@@ -8,8 +8,8 @@ import {
/**
* The settings cache lives in one process and is updated by whichever process
* wrote the setting. On SQLite that is the only process there is. On Postgres
* and MySQL — the reason those exist here is to let several instances share one
* database — a setting changed on one replica would otherwise never reach the
* and MySQL, the reason those exist here is to let several instances share one
* database, a setting changed on one replica would otherwise never reach the
* others, because the synchronous read cannot go back to the database.
*
* Re-priming on a timer does not make settings immediately consistent. It
@@ -57,7 +57,7 @@ describe("settings cache refresh", () => {
it("keeps running after a refresh throws", async () => {
// A transient database blip must not stop the loop, or the replica is stuck
// on stale settings until it restarts — the exact failure this prevents.
// on stale settings until it restarts, the exact failure this prevents.
const refresh = vi
.fn()
.mockRejectedValueOnce(new Error("connection reset"))
@@ -20,7 +20,7 @@ import type { DatabaseDialect } from "../../../database/db/dialect.js";
*
* Defaults to SQLite, so `npm test` behaves as it always has and needs no
* server. Set TEST_DIALECT=postgres or mysql, plus TEST_DATABASE_URL, to run
* the same tests against a real one — see the database-dialects CI job.
* the same tests against a real one, see the database-dialects CI job.
*/
export function testDialect(env = process.env): DatabaseDialect {
const value = env.TEST_DIALECT?.trim().toLowerCase();
@@ -125,7 +125,7 @@ export class TestSqliteDatabase {
* Portable write for test setup.
*
* Separate from query() because better-sqlite3 refuses `.all()` on a
* statement that returns no rows — "This statement does not return data".
* statement that returns no rows, "This statement does not return data".
*/
async run(statement: SQL): Promise<void> {
const context = this.context;
@@ -207,7 +207,7 @@ async function truncateAll(context: DatabaseContext): Promise<void> {
}
/**
* Splits seed SQL into statements, ignoring semicolons inside string literals —
* Splits seed SQL into statements, ignoring semicolons inside string literals,
* JSON payloads in the fixtures contain them.
*/
function splitStatements(sql: string): string[] {
@@ -274,7 +274,7 @@ function portableSql(statement: string, dialect: DatabaseDialect): string {
if (dialect !== "mysql") return out;
// Only the column list, before VALUES. A blanket replace also mangles the
// double quotes inside JSON payloads in the values — '{"slots":[]}' became
// double quotes inside JSON payloads in the values, '{"slots":[]}' became
// '{`slots`:[]}', which is valid SQL and silently wrong data.
const split = /^(.*?\bVALUES\b)(.*)$/is.exec(out);
if (!split) return out.replace(/"([a-z_]+)"/g, "`$1`");
@@ -354,7 +354,7 @@ async function resyncAutoIncrement(
for (const table of tables) {
// Only tables whose id is generated. A text primary key, like users.id,
// has no sequence and no counter to move, and a table keyed on something
// else entirely — host_sidebar_preferences.user_id — has no id at all.
// else entirely, host_sidebar_preferences.user_id, has no id at all.
if (context.dialect === "postgres") {
// pg_get_serial_sequence() raises 42703 rather than returning null when
// the column is missing, so let information_schema decide whether there
@@ -408,7 +408,7 @@ async function resyncAutoIncrement(
*
* Every test builds a fixture, and each would otherwise re-run the migrator
* against the same shared database. drizzle's journal makes that a no-op only
* when the first run finished — several fixtures racing inside one file hit
* when the first run finished, several fixtures racing inside one file hit
* "table already exists" instead.
*/
const migrations = new Map<string, Promise<void>>();
@@ -436,7 +436,7 @@ let cachedSqliteSchema: string | null = null;
* The full schema, from the generated SQLite migrations rather than
* hand-written DDL in each test file.
*
* Tests used to declare a cut-down version of every table they touched — a
* Tests used to declare a cut-down version of every table they touched, a
* `users` with five columns where the real one has thirty. That drifts from the
* schema silently, and it is the reason the same tests could not be pointed at
* another engine.
@@ -34,19 +34,19 @@ describe("UserPreferenceRepository", () => {
reopenTabsOnLogin: true,
theme: "dark",
storageMode: "local",
commandAutocomplete: true,
commandPaletteEnabled: true,
});
expect(created).toMatchObject({
userId: "user-1",
reopenTabsOnLogin: true,
theme: "dark",
storageMode: "local",
commandAutocomplete: true,
commandPaletteEnabled: true,
});
const updated = await repo.upsert("user-1", {
theme: "light",
commandAutocomplete: false,
commandPaletteEnabled: false,
updatedAt: "2026-06-27T00:00:00.000Z",
});
expect(updated).toMatchObject({
@@ -54,7 +54,7 @@ describe("UserPreferenceRepository", () => {
reopenTabsOnLogin: true,
theme: "light",
storageMode: "local",
commandAutocomplete: false,
commandPaletteEnabled: false,
});
});
@@ -56,7 +56,6 @@ describe("UserRepository and SessionRepository", () => {
username: "admin",
passwordHash: "hash",
isAdmin: true,
isOidc: false,
});
expect(await repo.users.countAdmins()).toBe(1);
@@ -69,13 +68,15 @@ describe("UserRepository and SessionRepository", () => {
).toEqual(["user-1"]);
expect(await repo.users.listByIds([])).toEqual([]);
const updated = await repo.users.update("user-1", {
oidcIdentifier: "oidc:admin",
});
expect(updated?.oidcIdentifier).toBe("oidc:admin");
expect((await repo.users.findByExternalIdentifier("oidc:admin"))?.id).toBe(
"user-1",
);
const updated = await repo.users.update("user-1", { isAdmin: false });
expect(updated).toMatchObject({ isAdmin: false, isExternal: false });
await adapter!.exec(`
INSERT INTO user_external_identities (user_id, provider_id, subject)
VALUES ('user-1', '3', 'sub-1');
`);
expect((await repo.users.findById("user-1"))?.isExternal).toBe(true);
expect((await repo.users.listAll())[0].isExternal).toBe(true);
expect(await repo.users.delete("user-1")).toBe(true);
expect(await repo.users.findById("user-1")).toBeNull();
@@ -88,13 +89,11 @@ describe("UserRepository and SessionRepository", () => {
id: "user-1",
username: "first",
passwordHash: "hash",
isOidc: false,
});
const second = await repo.users.createFirstLocalUser({
id: "user-2",
username: "second",
passwordHash: "hash",
isOidc: false,
});
expect(first.isFirstUser).toBe(true);
@@ -112,27 +111,18 @@ describe("UserRepository and SessionRepository", () => {
username: "first",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:provider:first",
ssoProviderId: 1,
});
const providerAdmin = await repo.users.createFirstSsoUser({
id: "user-2",
username: "provider-admin",
passwordHash: "",
isAdmin: true,
isOidc: true,
oidcIdentifier: "ldap:provider:admin",
ssoProviderId: 1,
});
const regular = await repo.users.createFirstSsoUser({
id: "user-3",
username: "regular",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:provider:regular",
ssoProviderId: 1,
});
expect(first.isFirstUser).toBe(true);
@@ -157,7 +147,6 @@ describe("UserRepository and SessionRepository", () => {
username: "admin",
passwordHash: "hash",
isAdmin: true,
isOidc: false,
});
await repo.users.update("user-1", { isAdmin: false });
await repo.users.delete("user-1");
@@ -172,7 +161,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
@@ -208,7 +196,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
id: "session-1",
@@ -250,7 +237,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
id: "session-1",
@@ -281,7 +267,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
for (const id of ["keep", "drop-1", "drop-2"]) {
@@ -308,7 +293,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
@@ -1,37 +0,0 @@
import { describe, it, expect } from "vitest";
import { shouldShowDonationModal } from "../../../database/routes/donation-modal-utils.js";
describe("shouldShowDonationModal", () => {
const now = Date.parse("2026-07-17T00:00:00.000Z");
it("returns false when the user already dismissed it", () => {
const registeredAt = new Date(now - 60 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, true, now)).toBe(false);
});
it("returns false before the 30 day mark", () => {
const registeredAt = new Date(now - 29 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(false);
});
it("returns true at exactly 30 days", () => {
const registeredAt = new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
});
it("returns true well past the 30 day mark", () => {
const registeredAt = new Date(
now - 200 * 24 * 60 * 60 * 1000,
).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
});
it("returns false for an unparseable registeredAt", () => {
expect(shouldShowDonationModal("not-a-date", false, now)).toBe(false);
});
it("treats a backdated registeredAt for pre-existing users as immediately eligible", () => {
const registeredAt = new Date(now - 31 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
});
});
@@ -1,8 +1,10 @@
import { describe, it, expect } from "vitest";
import {
importedHostUsername,
notifyImportedHosts,
parseSSHConfig,
} from "../../../database/routes/host-bulk-routes.js";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
describe("parseSSHConfig", () => {
it("parses a basic Host block", () => {
@@ -126,3 +128,21 @@ describe("importedHostUsername", () => {
);
});
});
describe("notifyImportedHosts", () => {
it("sends a host update for every imported host", () => {
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostUpdated, (payload) => {
seen.push(payload);
});
try {
notifyImportedHosts("user-1", [4, 7]);
} finally {
off();
}
expect(seen).toEqual([
{ hostId: 4, userId: "user-1" },
{ hostId: 7, userId: "user-1" },
]);
});
});
@@ -96,9 +96,8 @@ it.each([false, true])(
overwrite ? 19 : 71,
expect.objectContaining({ pluginSettings: host.pluginSettings }),
);
expect(JSON.parse(saved.terminalConfig)).toEqual({
macOptionIsMeta: false,
});
// The 2.8 terminalConfig reaches the plugin, never a host column.
expect(saved).not.toHaveProperty("terminalConfig");
},
);
@@ -4,6 +4,7 @@ import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
isNonEmptyString,
keepsStoredSshAuth,
isOptionalBoolean,
isValidPort,
normalizeImportedHost,
@@ -284,7 +285,7 @@ describe("stripSensitiveFields", () => {
expect(result.hasKey).toBe(false);
});
it("detects sudo password stored only in nested terminalConfig", () => {
it("strips a sudo password nested in terminalConfig", () => {
const result = stripSensitiveFields({
name: "web",
terminalConfig: {
@@ -292,7 +293,7 @@ describe("stripSensitiveFields", () => {
sudoPassword: "nested-only-sudo",
},
});
expect(result.hasSudoPassword).toBe(true);
expect(result.hasSudoPassword).toBe(false);
expect(
(result.terminalConfig as Record<string, unknown>).sudoPassword,
).toBeUndefined();
@@ -483,25 +484,6 @@ describe("transformHostResponse terminal fields", () => {
expect(host.terminalConfig).toEqual({ keepaliveInterval: 20 });
});
it("reads the options out of terminal_config before the boot copy", () => {
const host = row({
sshOptions: null,
terminalConfig: JSON.stringify({ agentForwarding: true }),
});
expect(host.sshOptions).toEqual({ agentForwarding: true });
});
it("surfaces a 2.8 sudo password for the sanitizers to strip", () => {
const host = row({
sudoPassword: null,
terminalConfig: JSON.stringify({ sudoPassword: "legacy" }),
});
const stripped = stripSensitiveFields(host);
expect(stripped.hasSudoPassword).toBe(true);
expect(stripped).not.toHaveProperty("sudoPassword");
expect(JSON.stringify(stripped)).not.toContain("legacy");
});
it("hides the owner's agent socket from a shared recipient", () => {
const shared = sanitizeHostForRecipient(
row({
@@ -516,3 +498,20 @@ describe("transformHostResponse terminal fields", () => {
expect(shared.terminalConfig).toEqual({ keepaliveInterval: 9 });
});
});
it("preserves a shared host sync identity for connect-only recipients", () => {
const result = sanitizeHostForRecipient(
{ id: 9, syncId: "remote-host-41", password: "secret", notes: "private" },
"connect",
);
expect(result).toEqual({ id: 9, syncId: "remote-host-41" });
});
describe("keepsStoredSshAuth", () => {
it("keeps stored secrets only when no auth type is sent", () => {
expect(keepsStoredSshAuth(undefined)).toBe(true);
expect(keepsStoredSshAuth(null)).toBe(true);
expect(keepsStoredSshAuth("password")).toBe(false);
expect(keepsStoredSshAuth("none")).toBe(false);
});
});
@@ -6,7 +6,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
interface Row {
pluginId: string;
@@ -10,17 +10,11 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
const { mergeStoredTerminalFields, parseTerminalConfig } =
const { mergeStoredSshOptions, parseTerminalConfig } =
await import("../../../database/routes/host-terminal-fields.js");
beforeEach(() => {
state.stored = {
terminalConfig: JSON.stringify({
theme: "nord",
sudoPassword: "legacy-sudo",
agentSocketPath: "/owner/agent",
startupSnippetId: 1,
}),
sshOptions: JSON.stringify({ agentSocketPath: "/owner/agent" }),
};
});
@@ -31,10 +25,10 @@ async function merge(
sshOptions?: string,
) {
const row: Record<string, unknown> = {
terminalConfig: "replaced wholesale",
terminalConfig: "from the payload",
...(sshOptions !== undefined ? { sshOptions } : {}),
};
const error = await mergeStoredTerminalFields(row, hostData, 1, "o", isOwner);
const error = await mergeStoredSshOptions(row, hostData, 1, "o", isOwner);
return { error, row };
}
@@ -47,43 +41,32 @@ describe("parseTerminalConfig", () => {
});
});
describe("mergeStoredTerminalFields", () => {
it("merges an editor save into the stored 2.8 keys instead of dropping them", async () => {
describe("mergeStoredSshOptions", () => {
it("never writes terminalConfig, which has no column", async () => {
const { error, row } = await merge({
terminalConfig: { startupSnippetId: 4 },
});
expect(error).toBeNull();
expect(JSON.parse(row.terminalConfig as string)).toEqual({
theme: "nord",
sudoPassword: "legacy-sudo",
agentSocketPath: "/owner/agent",
startupSnippetId: 4,
});
});
it("leaves terminal_config alone when the payload has none", async () => {
const { row } = await merge({});
expect(row).not.toHaveProperty("terminalConfig");
});
it("drops the legacy sudo copy once the owner saves one on its own", async () => {
const { row } = await merge({ sudoPassword: "" });
expect(JSON.parse(row.terminalConfig as string)).not.toHaveProperty(
"sudoPassword",
it("leaves an owner's SSH options as sent", async () => {
const { row } = await merge(
{},
true,
JSON.stringify({ agentSocketPath: "/x" }),
);
expect(JSON.parse(row.sshOptions as string)).toEqual({
agentSocketPath: "/x",
});
});
it("keeps the owner's private keys through a shared editor's save", async () => {
it("keeps the owner's private SSH options through a shared editor's save", async () => {
const { row } = await merge(
{ terminalConfig: { startupSnippetId: 2 } },
{},
false,
JSON.stringify({ keepaliveInterval: 9 }),
JSON.stringify({ keepaliveInterval: 9, agentSocketPath: "/editor" }),
);
expect(JSON.parse(row.terminalConfig as string)).toMatchObject({
sudoPassword: "legacy-sudo",
agentSocketPath: "/owner/agent",
startupSnippetId: 2,
});
expect(JSON.parse(row.sshOptions as string)).toEqual({
keepaliveInterval: 9,
agentSocketPath: "/owner/agent",
@@ -6,6 +6,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginManageError } from "../../../plugins/manage.js";
import express from "express";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
@@ -161,6 +162,45 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
const manageMock = vi.hoisted(() => ({
listRegistry: vi.fn(),
installPlugin: vi.fn(),
updatePlugin: vi.fn(),
updateAllPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
setPluginOptions: vi.fn(),
setAllPluginChannels: vi.fn(),
getPluginDataSummary: vi.fn(),
getPluginChangelog: vi.fn(),
deletePluginData: vi.fn(),
planStateChange: vi.fn(),
setPluginState: vi.fn(),
getDeveloperMode: vi.fn(),
setDeveloperMode: vi.fn(),
stageUpload: vi.fn(),
installUpload: vi.fn(),
isLinkedDesktop: vi.fn(async () => false),
}));
const auditMock = vi.hoisted(() => ({ logAudit: vi.fn(async () => {}) }));
vi.mock("../../../plugins/manage.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../plugins/manage.js")>()),
...manageMock,
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: auditMock.logAudit,
getAuditUsername: async () => "admin",
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const onboardingMock = vi.hoisted(() => ({
describeOnboardingPlugins: vi.fn(),
applyOnboardingChoices: vi.fn(),
}));
vi.mock("../../../plugins/onboarding.js", () => onboardingMock);
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
.default;
@@ -236,9 +276,50 @@ describe("plugins route", () => {
expect(body[0].contributes).toEqual({
guest: true,
guestViews: ["shared"],
tabs: [],
panels: [],
});
expect(body[0]).not.toHaveProperty("capabilities");
expect(body[0]).not.toHaveProperty("lastError");
expect(body[0].version).toBe("");
});
it("leaves out dependencies that do not load on guest pages", async () => {
state.plugins.set(
"sharing",
makePlugin({
id: "sharing",
manifestJson: JSON.stringify({
contributes: {
guest: true,
guestViews: ["shared"],
tabs: [{ id: "collab", titleKey: "nav.collab" }],
},
dependencies: { terminal: "^1.0.0", desktop: "^1.0.0" },
optionalDependencies: { recorder: "^1.0.0" },
}),
}),
);
state.plugins.set(
"desktop",
makePlugin({
id: "desktop",
manifestJson: JSON.stringify({ contributes: { guest: true } }),
}),
);
state.plugins.set("terminal", makePlugin({ id: "terminal" }));
const res = await fetch(`${baseUrl}/plugins/public`);
const body = await res.json();
const sharing = body.find(
(plugin: { id: string }) => plugin.id === "sharing",
);
expect(sharing.dependencies).toEqual({ desktop: "^1.0.0" });
expect(sharing.optionalDependencies).toEqual({});
expect(sharing.contributes.tabs).toEqual([
{ id: "collab", titleKey: "nav.collab" },
]);
});
it("skips a plugin whose manifest does not parse", async () => {
@@ -314,6 +395,9 @@ describe("plugins route", () => {
expect(body[0].contributes).toEqual({
auth: { loginMethods: ["corp-sso"], secondFactors: [] },
});
expect(body.map((plugin: { version: string }) => plugin.version)).toEqual(
["", ""],
);
for (const plugin of body) {
expect(Object.keys(plugin).sort()).toEqual(
[
@@ -579,4 +663,447 @@ describe("plugins route", () => {
expect(state.grants).toHaveLength(1);
});
});
describe("onboarding plugin picker", () => {
const post = (body: unknown, user = "admin-1") =>
fetch(`${baseUrl}/plugins/onboarding/apply`, {
method: "POST",
headers: { "content-type": "application/json", "x-test-user-id": user },
body: JSON.stringify(body),
});
beforeEach(() => {
onboardingMock.describeOnboardingPlugins.mockReset();
onboardingMock.applyOnboardingChoices.mockReset();
manageMock.isLinkedDesktop.mockResolvedValue(false);
auditMock.logAudit.mockClear();
});
it("lists plugins for a manager only", async () => {
onboardingMock.describeOnboardingPlugins.mockResolvedValue({
pending: true,
managedByLinkedServer: false,
plugins: [],
});
const ok = await fetch(`${baseUrl}/plugins/onboarding`, {
headers: { "x-test-user-id": "admin-1" },
});
expect(ok.status).toBe(200);
expect(await ok.json()).toMatchObject({ pending: true });
const denied = await fetch(`${baseUrl}/plugins/onboarding`, {
headers: { "x-test-user-id": "user-2" },
});
expect(denied.status).toBe(403);
});
it("applies choices and audits each change", async () => {
onboardingMock.applyOnboardingChoices.mockResolvedValue({
resolved: {},
adjustments: [],
enabled: ["a"],
disabled: ["b"],
removed: ["c"],
failed: [],
});
const res = await post({
choices: { a: "enabled", b: "disabled", c: "remove" },
});
expect(res.status).toBe(200);
expect(onboardingMock.applyOnboardingChoices).toHaveBeenCalledWith(
{ a: "enabled", b: "disabled", c: "remove" },
{ dryRun: false },
);
const actions = auditMock.logAudit.mock.calls.map(
(call) => (call as unknown as [{ action: string }])[0].action,
);
expect(actions).toEqual([
"enable_plugin",
"disable_plugin",
"uninstall_plugin",
]);
});
it("rejects bad choices", async () => {
expect((await post({})).status).toBe(400);
expect((await post({ choices: ["a"] })).status).toBe(400);
expect((await post({ choices: { a: "delete" } })).status).toBe(400);
expect(
(await post({ choices: { a: "enabled" }, dryRun: "yes" })).status,
).toBe(400);
expect(onboardingMock.applyOnboardingChoices).not.toHaveBeenCalled();
});
it("refuses on a linked desktop and for non-managers", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
expect((await post({ choices: { a: "enabled" } })).status).toBe(409);
manageMock.isLinkedDesktop.mockResolvedValue(false);
expect((await post({ choices: { a: "enabled" } }, "user-2")).status).toBe(
403,
);
expect(onboardingMock.applyOnboardingChoices).not.toHaveBeenCalled();
});
});
describe("install, update, uninstall", () => {
const post = (path: string, body: unknown = {}, user = "admin-1") =>
fetch(`${baseUrl}${path}`, {
method: "POST",
headers: { "content-type": "application/json", "x-test-user-id": user },
body: JSON.stringify(body),
});
beforeEach(() => {
for (const fn of Object.values(manageMock)) fn.mockReset();
manageMock.isLinkedDesktop.mockResolvedValue(false);
auditMock.logAudit.mockClear();
});
it("installs a plugin as the caller and audits it", async () => {
manageMock.installPlugin.mockResolvedValue({
id: "docker",
version: "1.0.0",
state: "active",
});
const res = await post("/plugins/docker/install", {
version: "1.0.0",
capabilities: ["kv:own"],
});
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith("docker", {
version: "1.0.0",
channel: "stable",
userId: "admin-1",
capabilities: ["kv:own"],
});
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "install_plugin",
resourceId: "docker",
}),
);
});
it("installs on the beta channel when asked", async () => {
manageMock.installPlugin.mockResolvedValue({
id: "docker",
version: "1.1.0-beta.1",
state: "active",
});
const res = await post("/plugins/docker/install", {
channel: "beta",
capabilities: ["kv:own"],
});
expect(res.status).toBe(200);
expect(manageMock.installPlugin).toHaveBeenCalledWith(
"docker",
expect.objectContaining({ channel: "beta" }),
);
expect(
(
await post("/plugins/docker/install", {
channel: "nightly",
capabilities: [],
})
).status,
).toBe(400);
});
it("moves every plugin to one channel", async () => {
manageMock.setAllPluginChannels.mockResolvedValue({
changed: ["docker"],
});
const res = await post("/plugins/channel", { channel: "beta" });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ changed: ["docker"] });
expect(manageMock.setAllPluginChannels).toHaveBeenCalledWith("beta");
expect((await post("/plugins/channel", { channel: "x" })).status).toBe(
400,
);
});
it("saves a plugin's channel and refuses an unknown one", async () => {
manageMock.setPluginOptions.mockResolvedValue({
autoUpdate: false,
pinnedVersion: null,
channel: "beta",
});
const patch = (body: unknown) =>
fetch(`${baseUrl}/plugins/docker/options`, {
method: "PATCH",
headers: {
"content-type": "application/json",
"x-test-user-id": "admin-1",
},
body: JSON.stringify(body),
});
expect((await patch({ channel: "beta" })).status).toBe(200);
expect(manageMock.setPluginOptions).toHaveBeenCalledWith("docker", {
autoUpdate: undefined,
pinned: undefined,
channel: "beta",
});
expect((await patch({ channel: "nightly" })).status).toBe(400);
});
it("rejects a bad id or version before touching anything", async () => {
expect((await post("/plugins/..%2Fx/install")).status).toBe(400);
expect(
(await post("/plugins/docker/install", { version: 7 })).status,
).toBe(400);
expect(
(await post("/plugins/docker/install", { capabilities: "kv:own" }))
.status,
).toBe(400);
expect(
(await post("/plugins/docker/install", { capabilities: [1] })).status,
).toBe(400);
expect(manageMock.installPlugin).not.toHaveBeenCalled();
});
it("passes a refusal through with its code and details", async () => {
manageMock.updatePlugin.mockRejectedValue(
new PluginManageError("new caps", 409, "CAPABILITIES_ADDED", {
capabilities: ["notify:send"],
}),
);
const res = await post("/plugins/docker/update", {});
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({
code: "CAPABILITIES_ADDED",
capabilities: ["notify:send"],
});
expect(manageMock.updatePlugin).toHaveBeenCalledWith("docker", {
version: undefined,
userId: "admin-1",
acceptCapabilities: false,
capabilities: undefined,
});
});
it("passes the consented list on an accepted update", async () => {
manageMock.updatePlugin.mockResolvedValue({
id: "docker",
version: "1.1.0",
state: "active",
});
await post("/plugins/docker/update", {
acceptCapabilities: true,
capabilities: ["notify:send"],
});
expect(manageMock.updatePlugin).toHaveBeenCalledWith("docker", {
version: undefined,
userId: "admin-1",
acceptCapabilities: true,
capabilities: ["notify:send"],
});
});
it("uninstalls", async () => {
manageMock.uninstallPlugin.mockResolvedValue({
id: "docker",
removed: { tables: ["p_docker_x"], kvKeys: 0 },
});
const res = await fetch(`${baseUrl}/plugins/docker`, {
method: "DELETE",
});
expect(res.status).toBe(200);
expect(manageMock.uninstallPlugin).toHaveBeenCalledWith("docker");
});
it("refuses changes on a linked desktop", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
const res = await post("/plugins/docker/install");
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("MANAGED_BY_SERVER");
expect(manageMock.installPlugin).not.toHaveBeenCalled();
});
it("previews a state change without making it", async () => {
state.plugins.set("sample-plugin", makePlugin());
manageMock.planStateChange.mockResolvedValue({
enable: [],
disable: ["addon"],
missing: [],
});
const res = await fetch(
`${baseUrl}/plugins/sample-plugin/state?dryRun=1`,
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: false }),
},
);
expect(await res.json()).toMatchObject({ disable: ["addon"] });
expect(manageMock.setPluginState).not.toHaveBeenCalled();
});
it("returns an installed plugin's release notes", async () => {
manageMock.getPluginChangelog.mockResolvedValue([
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
]);
const res = await fetch(`${baseUrl}/plugins/docker/changelog`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "docker",
releases: [
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
],
});
expect(manageMock.getPluginChangelog).toHaveBeenCalledWith("docker");
});
it("answers 502 when the registry is unreachable", async () => {
manageMock.listRegistry.mockRejectedValue(new Error("offline"));
const res = await fetch(`${baseUrl}/plugins/registry`);
expect(res.status).toBe(502);
});
it("403s a non-admin on every management route", async () => {
const user = "regular-user";
const headers = {
"content-type": "application/json",
"x-test-user-id": user,
};
const calls = [
fetch(`${baseUrl}/plugins/registry`, { headers }),
post("/plugins/docker/install", {}, user),
post("/plugins/docker/update", {}, user),
post("/plugins/update-all", {}, user),
post("/plugins/channel", { channel: "beta" }, user),
fetch(`${baseUrl}/plugins/docker`, { method: "DELETE", headers }),
fetch(`${baseUrl}/plugins/docker/options`, {
method: "PATCH",
headers,
body: JSON.stringify({ autoUpdate: true }),
}),
fetch(`${baseUrl}/plugins/docker/data`, { headers }),
fetch(`${baseUrl}/plugins/docker/changelog`, { headers }),
fetch(`${baseUrl}/plugins/developer-mode`, { headers }),
fetch(`${baseUrl}/plugins/developer-mode`, {
method: "PUT",
headers,
body: JSON.stringify({ enabled: true }),
}),
fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: {
"content-type": "application/octet-stream",
"x-test-user-id": user,
},
body: Buffer.from("x"),
}),
post(`/plugins/upload/${"a".repeat(32)}/install`, {}, user),
];
for (const res of await Promise.all(calls)) {
expect(res.status).toBe(403);
}
for (const fn of Object.values(manageMock)) {
if (fn !== manageMock.isLinkedDesktop) {
expect(fn).not.toHaveBeenCalled();
}
}
});
});
describe("developer mode and file installs", () => {
beforeEach(() => {
for (const fn of Object.values(manageMock)) fn.mockReset();
manageMock.isLinkedDesktop.mockResolvedValue(false);
auditMock.logAudit.mockClear();
});
it("reads and saves developer mode, auditing the change", async () => {
manageMock.getDeveloperMode.mockResolvedValue(true);
const read = await fetch(`${baseUrl}/plugins/developer-mode`);
expect(await read.json()).toEqual({ enabled: true, signedOnly: false });
const bad = await fetch(`${baseUrl}/plugins/developer-mode`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: "yes" }),
});
expect(bad.status).toBe(400);
const saved = await fetch(`${baseUrl}/plugins/developer-mode`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: false }),
});
expect(saved.status).toBe(200);
expect(manageMock.setDeveloperMode).toHaveBeenCalledWith(false);
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({ action: "plugin_developer_mode" }),
);
});
it("hands the raw upload to the stager", async () => {
manageMock.stageUpload.mockResolvedValue({ token: "t", id: "notes" });
const res = await fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: Buffer.from("archive"),
});
expect(res.status).toBe(200);
const [buffer] = manageMock.stageUpload.mock.calls[0];
expect(Buffer.from(buffer).toString()).toBe("archive");
});
it("refuses an upload that is not octet-stream", async () => {
const res = await fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
expect(manageMock.stageUpload).not.toHaveBeenCalled();
});
it("passes the developer mode refusal through", async () => {
manageMock.stageUpload.mockRejectedValue(
new PluginManageError("off", 403, "DEVELOPER_MODE_OFF"),
);
const res = await fetch(`${baseUrl}/plugins/upload`, {
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: Buffer.from("archive"),
});
expect(res.status).toBe(403);
expect((await res.json()).code).toBe("DEVELOPER_MODE_OFF");
});
it("installs a staged upload and audits it as unverified", async () => {
manageMock.installUpload.mockResolvedValue({
id: "notes",
version: "0.1.0",
state: "active",
});
const token = "b".repeat(32);
const res = await fetch(`${baseUrl}/plugins/upload/${token}/install`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capabilities: ["kv:own"] }),
});
expect(res.status).toBe(200);
expect(manageMock.installUpload).toHaveBeenCalledWith(token, {
userId: "admin-1",
capabilities: ["kv:own"],
});
expect(auditMock.logAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "install_plugin_from_file",
resourceId: "notes",
}),
);
});
it("rejects a malformed upload token", async () => {
const res = await fetch(`${baseUrl}/plugins/upload/nope/install`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ capabilities: [] }),
});
expect(res.status).toBe(400);
expect(manageMock.installUpload).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Grant {
id: number;
userId: string;
grantedBy: string;
permissionLevel: string;
}
const state = vi.hoisted(() => ({
hostGrants: [] as Grant[],
credentialGrants: [] as Grant[],
}));
const canManage = (grants: Grant[], userId: string) =>
userId === "owner" ||
grants.some((g) => g.userId === userId && g.permissionLevel === "manage");
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware: () => vi.fn(),
createDataAccessMiddleware: () => vi.fn(),
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
SHARE_PERMISSION_LEVELS: ["connect", "view", "edit", "manage"],
PermissionManager: {
getInstance: () => ({
canAccessHost: async (userId: string) => ({
hasAccess: canManage(state.hostGrants, userId),
isOwner: userId === "owner",
}),
requirePermission: () => vi.fn(),
requireAdmin: () => vi.fn(),
invalidateUserPermissionCache: vi.fn(),
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async () => "owner",
}),
createCurrentRbacAccessRepository: () => ({
listHostAccess: async () => [...state.hostGrants],
revokeHostAccess: async (id: number) => {
state.hostGrants = state.hostGrants.filter((g) => g.id !== id);
},
listHostIdsGrantedBy: async (userId: string) =>
state.hostGrants.some((g) => g.grantedBy === userId) ? [1] : [],
}),
createCurrentCredentialRepository: () => ({
findById: async () => ({ id: 5, userId: "owner" }),
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentCredentialAccessRepository: () => ({
listForCredential: async () => [...state.credentialGrants],
findActiveGrant: async (_id: number, userId: string) =>
state.credentialGrants.find((g) => g.userId === userId) ?? null,
revoke: async (id: number) => {
state.credentialGrants = state.credentialGrants.filter(
(g) => g.id !== id,
);
},
listCredentialIdsGrantedBy: async () => [],
}),
}));
const { pruneHostGrants, pruneCredentialGrants } =
await import("../../../database/routes/rbac.js");
const chain = (): Grant[] => [
{ id: 2, userId: "bob", grantedBy: "alice", permissionLevel: "manage" },
{ id: 3, userId: "carol", grantedBy: "bob", permissionLevel: "view" },
{ id: 4, userId: "dave", grantedBy: "owner", permissionLevel: "view" },
];
describe("share pruning", () => {
beforeEach(() => {
state.hostGrants = chain();
state.credentialGrants = chain();
});
it("drops host shares passed on by someone who lost manage", async () => {
expect(await pruneHostGrants(1)).toBe(2);
expect(state.hostGrants.map((g) => g.userId)).toEqual(["dave"]);
});
it("keeps shares while the granter can still manage", async () => {
state.hostGrants.push({
id: 1,
userId: "alice",
grantedBy: "owner",
permissionLevel: "manage",
});
expect(await pruneHostGrants(1)).toBe(0);
expect(state.hostGrants).toHaveLength(4);
});
it("drops credential shares the same way", async () => {
expect(await pruneCredentialGrants(5)).toBe(2);
expect(state.credentialGrants.map((g) => g.userId)).toEqual(["dave"]);
});
});
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { UI_ONBOARDING_VERSION } from "../../../../types/ui-preferences.js";
import { LEGACY_SEEN } from "../../../../types/onboarding.js";
const state = vi.hoisted(() => ({
row: null as { data: string } | null,
@@ -80,18 +80,16 @@ afterEach(async () => {
describe("ui-preferences onboarding backfill", () => {
it("treats an old account with no row as onboarded", async () => {
expect((await get()).onboarding.completedVersion).toBe(
UI_ONBOARDING_VERSION,
);
const onboarding = (await get()).onboarding;
expect(onboarding.seen).toEqual(LEGACY_SEEN);
expect(onboarding.baselinePending).toBe(true);
});
it("keeps an old account onboarded after its first write", async () => {
await put({ overrides: { "plugin:x": { a: 1 } } });
const stored = JSON.parse(state.row!.data);
expect(stored.onboarding.completedVersion).toBe(UI_ONBOARDING_VERSION);
expect((await get()).onboarding.completedVersion).toBe(
UI_ONBOARDING_VERSION,
);
expect(stored.onboarding.seen).toEqual(LEGACY_SEEN);
expect((await get()).onboarding.seen).toEqual(LEGACY_SEEN);
});
it("repairs a row an earlier write stored as not completed", async () => {
@@ -100,29 +98,61 @@ describe("ui-preferences onboarding backfill", () => {
onboarding: { completedVersion: 0, completedAt: null, skipped: false },
}),
};
expect((await get()).onboarding.completedVersion).toBe(
UI_ONBOARDING_VERSION,
);
expect((await get()).onboarding.seen).toEqual(LEGACY_SEEN);
});
it("migrates the old completedVersion shape", async () => {
state.registeredAt = NEW;
state.row = {
data: JSON.stringify({
onboarding: { completedVersion: 2, completedAt: NEW, skipped: false },
}),
};
const onboarding = (await get()).onboarding;
expect(onboarding.seen).toEqual(LEGACY_SEEN);
expect(onboarding.baselinePending).toBe(true);
});
it("still shows onboarding to a new account", async () => {
state.registeredAt = NEW;
await put({ preset: "simple" });
expect((await get()).onboarding.completedVersion).toBe(0);
expect((await get()).onboarding.seen).toEqual({});
});
});
it("does not hide a newer onboarding version from someone who finished an older one", async () => {
state.row = {
data: JSON.stringify({
onboarding: {
completedVersion: UI_ONBOARDING_VERSION - 1,
completedAt: OLD,
skipped: false,
},
}),
};
expect((await get()).onboarding.completedVersion).toBe(
UI_ONBOARDING_VERSION - 1,
);
describe("ui-preferences onboarding seen merge", () => {
beforeEach(() => {
state.registeredAt = NEW;
});
it("keeps the higher version per step", async () => {
await put({ onboarding: { seen: { welcome: 2, "rd:guacd": 1 } } });
await put({ onboarding: { seen: { welcome: 1, appearance: 1 } } });
expect((await get()).onboarding.seen).toEqual({
welcome: 2,
"rd:guacd": 1,
appearance: 1,
});
});
it("drops invalid seen entries", async () => {
await put({
onboarding: { seen: { ok: 1, zero: 0, text: "1", frac: 1.5 } },
});
expect((await get()).onboarding.seen).toEqual({ ok: 1 });
});
it("does not clear completedAt when a later write omits it", async () => {
await put({ onboarding: { completedAt: NEW, seen: { done: 1 } } });
await put({ onboarding: { seen: { "x:y": 1 } } });
expect((await get()).onboarding.completedAt).toBe(NEW);
});
it("clears baselinePending when told to", async () => {
state.registeredAt = OLD;
await put({ onboarding: { baselinePending: false, seen: { "x:y": 1 } } });
const onboarding = (await get()).onboarding;
expect(onboarding.baselinePending).toBe(false);
expect(onboarding.seen["x:y"]).toBe(1);
});
});
@@ -13,10 +13,7 @@ interface User {
id: string;
username: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash: string;
oidcIdentifier?: string | null;
clientId?: string;
}
const h = vi.hoisted(() => ({
@@ -30,14 +27,21 @@ const h = vi.hoisted(() => ({
revoked: [] as string[],
}));
function withIdentities(user: User | undefined) {
if (!user) return null;
return {
...user,
isExternal: h.identities.some((row) => row.userId === user.id),
};
}
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => h.users.get(id) ?? null,
findById: async (id: string) => withIdentities(h.users.get(id)),
findByUsername: async (username: string) =>
[...h.users.values()].find((user) => user.username === username) ?? null,
update: async (id: string, changes: Partial<User>) => {
Object.assign(h.users.get(id)!, changes);
},
withIdentities(
[...h.users.values()].find((user) => user.username === username),
),
}),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async (userId: string) =>
@@ -84,7 +88,6 @@ beforeEach(async () => {
id: "admin",
username: "admin",
isAdmin: true,
isOidc: false,
passwordHash: "x",
},
],
@@ -94,9 +97,7 @@ beforeEach(async () => {
id: "sso",
username: "alice-sso",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "ldap:4:alice",
},
],
[
@@ -105,7 +106,6 @@ beforeEach(async () => {
id: "local",
username: "alice",
isAdmin: false,
isOidc: false,
passwordHash: "hash",
},
],
@@ -149,16 +149,12 @@ function post(path: string, body: unknown, user = "admin") {
}
describe("linking an external account to a password account", () => {
it("moves the identities and the old identifier, then removes the external account", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
it("moves the identities, then removes the external account", async () => {
const response = await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice",
});
expect(response.status).toBe(200);
expect(h.users.get("local")).toMatchObject({
isOidc: true,
oidcIdentifier: "ldap:4:alice",
});
expect(h.identities).toEqual([
{ userId: "local", providerId: "ldap:4", subject: "alice" },
]);
@@ -166,13 +162,13 @@ describe("linking an external account to a password account", () => {
expect(h.deleted).toEqual(["sso"]);
});
it("takes the new path and field name", async () => {
const response = await post("/link-external-to-password", {
externalUserId: "sso",
it("no longer takes the 2.8 path and field name", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice",
});
expect(response.status).toBe(200);
expect(h.identities[0].userId).toBe("local");
expect(response.status).toBe(404);
expect(h.identities[0].userId).toBe("sso");
});
it("refuses a target that already signs in externally", async () => {
@@ -186,8 +182,8 @@ describe("linking an external account to a password account", () => {
it("is for admins only", async () => {
const response = await post(
"/link-oidc-to-password",
{ oidcUserId: "sso", targetUsername: "alice" },
"/link-external-to-password",
{ externalUserId: "sso", targetUsername: "alice" },
"local",
);
expect(response.status).toBe(403);
@@ -195,8 +191,8 @@ describe("linking an external account to a password account", () => {
});
it("refuses a target that is not a password account", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
const response = await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice-sso",
});
expect(response.status).toBe(400);
@@ -207,17 +203,13 @@ describe("linking an external account to a password account", () => {
id: "orig",
username: "bob",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-9",
});
h.users.set("dup", {
id: "dup",
username: "bob-1",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: null,
});
h.identities.push(
{ userId: "orig", providerId: "legacy-oidc", subject: "sub-9" },
@@ -231,7 +223,6 @@ describe("linking an external account to a password account", () => {
expect(response.status).toBe(200);
expect(h.deleted).toEqual(["dup"]);
expect(h.users.get("orig")!.oidcIdentifier).toBe("sub-9");
expect(
h.identities
.filter((row) => row.userId === "orig")
@@ -244,9 +235,7 @@ describe("linking an external account to a password account", () => {
id: "other",
username: "carol",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-x",
});
h.identities.push({
userId: "other",
@@ -264,22 +253,6 @@ describe("linking an external account to a password account", () => {
describe("unlinking", () => {
it("drops the identities so the provider no longer signs in as the user", async () => {
await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice",
});
const response = await post("/unlink-oidc-from-password", {
userId: "local",
});
expect(response.status).toBe(200);
expect(h.users.get("local")).toMatchObject({
isOidc: false,
oidcIdentifier: null,
});
expect(h.identities).toEqual([]);
});
it("takes the new path", async () => {
await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice",
@@ -291,8 +264,15 @@ describe("unlinking", () => {
expect(h.identities).toEqual([]);
});
it("refuses to leave an account with no way in", async () => {
it("no longer takes the 2.8 path", async () => {
const response = await post("/unlink-oidc-from-password", {
userId: "local",
});
expect(response.status).toBe(404);
});
it("refuses to leave an account with no way in", async () => {
const response = await post("/unlink-external-from-password", {
userId: "sso",
});
expect(response.status).toBe(400);
@@ -0,0 +1,100 @@
/**
* The web app's socket ticket. The cookie rides along from a sibling
* subdomain too, so a ticket only goes to a page on this server.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import express, { type RequestHandler } from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import type { AuthManager } from "../../../utils/auth-manager.js";
const state = vi.hoisted(() => ({
sessionId: "s1" as string | undefined,
apiKeyId: undefined as string | undefined,
}));
vi.mock("../../../utils/logger.js", () => ({
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const { registerUserSocketTicketRoutes } =
await import("../../../database/routes/user-socket-ticket-routes.js");
const issueSocketTicket = vi.fn(
async (userId: string, sessionId: string) => `ticket:${userId}:${sessionId}`,
);
const authenticate: RequestHandler = (req, _res, next) => {
Object.assign(req, {
userId: "u1",
sessionId: state.sessionId,
apiKeyId: state.apiKeyId,
});
next();
};
function app() {
const router = express.Router();
registerUserSocketTicketRoutes(router, {
authenticateJWT: authenticate,
authManager: { issueSocketTicket } as unknown as AuthManager,
});
const server = express();
server.use(cookieParser());
server.use("/users", router);
return server;
}
beforeEach(() => {
state.sessionId = "s1";
state.apiKeyId = undefined;
issueSocketTicket.mockClear();
});
describe("POST /users/ws-ticket", () => {
it("gives this server's own page a ticket for its session", async () => {
const res = await request(app())
.post("/users/ws-ticket")
.set("Host", "termix.example.com")
.set("Origin", "https://termix.example.com")
.set("Cookie", "jwt=session-token");
expect(res.status).toBe(200);
expect(res.body.ticket).toBe("ticket:u1:s1");
expect(res.headers["cache-control"]).toBe("no-store");
});
it("trusts the browser's same-origin mark behind a proxy that rewrites Host", async () => {
const res = await request(app())
.post("/users/ws-ticket")
.set("Host", "10.0.0.5:8080")
.set("Origin", "https://termix.example.com")
.set("Sec-Fetch-Site", "same-origin")
.set("Cookie", "jwt=session-token");
expect(res.status).toBe(200);
});
it("refuses a page on a sibling subdomain", async () => {
const res = await request(app())
.post("/users/ws-ticket")
.set("Host", "termix.example.com")
.set("Origin", "https://evil.example.com")
.set("Sec-Fetch-Site", "same-site")
.set("Cookie", "jwt=session-token");
expect(res.status).toBe(403);
expect(issueSocketTicket).not.toHaveBeenCalled();
});
it("refuses an API key, which has no session to bind to", async () => {
state.sessionId = undefined;
state.apiKeyId = "key-1";
const res = await request(app()).post("/users/ws-ticket");
expect(res.status).toBe(401);
expect(issueSocketTicket).not.toHaveBeenCalled();
});
});
@@ -21,7 +21,10 @@ const route = ast.statements.find(
) as ts.ExpressionStatement;
const callback = (route.expression as ts.CallExpression).arguments[2];
async function invoke(fetchGitHubAPI: ReturnType<typeof vi.fn>) {
async function invoke(
fetchLatestRelease: ReturnType<typeof vi.fn>,
channel = "stable",
) {
const handler = runInNewContext(
ts.transpileModule(`(${callback.getText(ast)})`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
@@ -29,10 +32,9 @@ async function invoke(fetchGitHubAPI: ReturnType<typeof vi.fn>) {
{
getLocalVersion: () => "2.7.1",
databaseLogger: { warn: vi.fn(), error: vi.fn() },
fetchGitHubAPI,
REPO_OWNER: "Termix-SSH",
REPO_NAME: "Termix",
compareSemver: () => -1,
fetchLatestRelease,
getCoreChannel: async () => channel,
updateStatus: () => "requires_update",
},
);
const res = { json: vi.fn(), status: vi.fn(), send: vi.fn() };
@@ -50,23 +52,33 @@ it("keeps the installed version when the update service is unreachable", async (
expect(res.status).not.toHaveBeenCalled();
});
it("keeps the installed version if the release has no parseable version", async () => {
const res = await invoke(
vi.fn().mockResolvedValue({ data: { tag_name: "nightly" } }),
);
const res = await invoke(vi.fn().mockResolvedValue(null));
expect(res.json).toHaveBeenCalledWith({
localVersion: "2.7.1",
status: "unknown",
channel: "stable",
});
});
it("still reports a newer release when the check succeeds", async () => {
const res = await invoke(
vi.fn().mockResolvedValue({ data: { tag_name: "2.8.0" } }),
vi.fn().mockResolvedValue({ version: "2.8.0", tagName: "v2.8.0" }),
);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
localVersion: "2.7.1",
status: "requires_update",
remoteVersion: "2.8.0",
channel: "stable",
}),
);
});
it("checks the channel the instance is on", async () => {
const fetchLatest = vi
.fn()
.mockResolvedValue({ version: "2.8.0-beta.1", tagName: "v2.8.0-beta.1" });
const res = await invoke(fetchLatest, "beta");
expect(fetchLatest).toHaveBeenCalledWith("beta");
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ channel: "beta", remoteVersion: "2.8.0-beta.1" }),
);
});