release-2.9.0 (#1532)

* fix(macos): skip single-instance lock in Mac App Store builds (#1454)

fix(macos): skip single-instance lock in Mac App Store builds

* Fix/backend optional sharp (#1455)

Fix/backend optional sharp

* feat: issue #1218 (#1218)

https://github.com/Termix-SSH/Support/issues/1218

* feat: Add option to use saved global custom theme under Connection Defaults (#1209)

https://github.com/Termix-SSH/Support/issues/1209

* feat: Add Fleet Sharing functionality to the UI (#1228)

https://github.com/Termix-SSH/Support/issues/1228

* feat: Add a list view to the Docker management page (#1237)

https://github.com/Termix-SSH/Support/issues/1237

* feat: issue #1264 (#1264)

https://github.com/Termix-SSH/Support/issues/1264

* feat: 2FA With FortiToken (#1288)

https://github.com/Termix-SSH/Support/issues/1288

* feat: Allow to disable showing paths to folders (#1274)

https://github.com/Termix-SSH/Support/issues/1274

* feat: Sync Network Graph between Desktop and Remote Server (#1245)

https://github.com/Termix-SSH/Support/issues/1245

* feat: Version Number on offline servers (#1291)

https://github.com/Termix-SSH/Support/issues/1291

* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)

https://github.com/Termix-SSH/Support/issues/1302

* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)

https://github.com/Termix-SSH/Support/issues/1289

* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)

https://github.com/Termix-SSH/Support/issues/1303

* fix: I can't send a file on its own. (#1304)

https://github.com/Termix-SSH/Support/issues/1304

* fix: sudo password not autofilling (#1248)

https://github.com/Termix-SSH/Support/issues/1248

* fix: node-MainThread (#1300)

https://github.com/Termix-SSH/Support/issues/1300

* fix: Tunnel authentication with shared credentials (#1295)

https://github.com/Termix-SSH/Support/issues/1295

* fix: host key updates not syncing, metrics 404 race on first connect

Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.

* chore: increment ver

* feat: add category and icon fields to plugin manifest schema

* ci(deps): bump the github-actions group with 2 updates (#1458)

Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

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

* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)

Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

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

* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)

Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

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

* feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.

* feat: add allowlisted host view for plugins

An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.

* feat: add plugin loader with manifest validation and crash restart

Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.

* feat: route internal events through a shared plugin event bus

Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.

* feat: add plugin ctx broker with permission gate and audit

Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.

* feat: dispatch plugin HTTP routes and wire the runtime into startup

ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.

* fix: update stale SFTP transfer test for single file destination paths

The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.

* feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.

* feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.

* feat: ship host metrics as a first-party in-process plugin

* fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.

* feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.

* fix: remove compiled plugin JS left behind in plugins/ after build

copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.

* feat: add a permission-gated backend service registry for plugins

* feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.

* feat: add cross-plugin secret references gated by the provider's RBAC permission

* feat: move proxmox discovery/import/sync into a first-party plugin

* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.

* feat: wire remote-desktop plugin into core and rename i18n keys

Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.

* feat: move fleets into a first-party plugin

* feat: move automations into a first-party plugin

* feat: remove legacy alert rules, superseded by automations

Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.

* feat: move network topology into a first-party plugin

* feat: move tailscale device discovery and settings into a plugin

Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.

* feat: move workspaces into a first-party plugin

* feat: move web endpoint into a first-party plugin

* feat: collapse plugin runtime to one tier behind a plugin SDK

* feat: make each plugin an npm workspace built by a termix-plugin CLI

* feat: give plugins their own tables, migrations and sync entities

* feat: serve plugin http and websockets from one mount instead of ports

* feat: namespace plugin permissions and keep them valid when a plugin is off

* feat: give plugins their own settings and one settings screen

* feat: load plugin frontends at runtime and remove plugin ids from the shell

* fix: mock the ai status fetch in its activate test

* feat: add auth extension points and one ssh connect pipeline

* feat: rebuild workspaces as the reference plugin

* fix: gate second factors after SSO/LDAP behind an admin setting

* feat: convert network-topology to a full plugin (B1)

* feat: move snippets backend and tables into a plugin (B2)

* feat: point core frontend snippets pickers at the plugin (B2)

* feat: add the snippets plugin (B2)

* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

* feat: finish the tailscale plugin, including SSH auth (B3)

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* chore: update proxmox and host-metrics locale files for B5

* chore: regenerate drizzle migration journals for B5

* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

* feat: move proxmox node stats and collectors into the proxmox plugin

* feat: drop proxmox host columns, use plugin settings instead

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* feat: convert file manager into a plugin

* feat: convert ssh tunnels into a plugin

* feat: finish the web-endpoint plugin, add ctx.desktop (B8)

* feat: move the terminal fully into the ssh-terminal plugin (B9)

* feat: convert tmux monitoring into a plugin (B10)

* feat: convert serial console into the serial plugin

* feat: move session sharing and collab rooms into a plugin (B12)

* feat: turn session recording into a plugin (B13)

* feat: finish the remote-desktop plugin (B14)

* feat: finish the host-metrics plugin, move host status into core (B16)

* feat: finish the docker plugin (B15)

* feat: finish the automations plugin (B17)

* feat: finish the ai plugin (B18)

* feat: finish the homepage plugin, move service links and canvas out of core (B19)

* feat: convert wake-on-lan into a plugin (B20)

* feat: convert secret-sources into a plugin (B20)

* feat: move totp and passkeys into plugins (C1)

* feat: move sso and ldap logins into plugins (C2)

* feat: move opkssh and warpgate into plugins (C3)

* feat: move step-ca into a plugin (C4)

* feat: move vault ssh signing into a plugin (C5)

* feat: move termix identity into a plugin (C6)

* feat: move acme certificate automation into a plugin (C7)

* fix: finish 2.9.0 plugin moves and get every check green

* chore: lock the plugin boundary and remove 2.9.0 leftovers

* fix: harden the plugin runtime after the 2.9.0 security review

* feat: add plugin packing, signing, pinned trust and tmxplug bundling

* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

* fix: give bundled plugin backends a real require for CJS deps

* feat: improve UI loading time and jitter

* feat: show credential sharing inline in the sidebar like host sharing

* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

* feat: unify alerts into one plugin, channels, rules and inbox

* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

* fix: set blacksmith builder cache-key to stop broken gha cache fallback

* fix: link plugin-sdk workspace before npm ci in docker deps stage

* fix: link every plugin workspace before npm ci in docker deps stage

* fix(host-metrics): exclude image and firmware filesystems (#1469)

* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(hosts): normalize imported jump host references (#1470)

* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent (#1476)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent

* fix(dashboard): reuse translated host status labels (#1475)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels

* docs(api): clarify local and remote version fields (#1474)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields

* fix(file-manager): preserve selected backgrounds on hover (#1473)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown

* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F

* fix: keep rounded-full circular in production builds

* fix(file-manager): cancel uploads and stop remote writes (#1477)

* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): apply compact density to directory tree (#1478)

* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): preserve font sizes in compact mode (#1480)

* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): prioritize file names in narrow lists (#1483)

* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(remote-desktop): probe the saved host guacd endpoint (#1481)

* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* fix(homepage): check allowlisted private service reachability (#1479)

* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain

* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)

* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* chore: simplify plugin readmes, changelogs and manifest descriptions

* fix: keep plugins loaded after signing in from the login screen

* fix(file-manager): accept OS file drops over existing rows (#1484)

* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(file-manager): pipeline SFTP download reads (#1485)

* feat: add plugin sdk build to beta release

* fix: make link-store secret check immune to random base64 matches

* fix(ldap): preserve the directory-provided bind DN (#1486)

* fix: compute beta changes across dev branches and move the beta tag

* chore: untrack plugin sdk ARCHITECTURE.md

* chore: remove references to plugin sdk ARCHITECTURE.md

* feat: merge host editor terminal tabs into one owned by ssh-terminal

* feat: add descriptions to plugin host enable toggles

* fix(file-manager): show download progress in file viewers (#1487)

* fix(electron): signal backend readiness over IPC (#1488)

* fix(split-screen): allow layout creation without randomUUID (#1490)

* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation

* fix(sidebar): remeasure visible rows after density changes (#1489)

* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation

* feat(split-screen): redesign split screen with free-form panes and pane picker

* feat(sidebar): add host click behavior setting, default to always opening a new tab

* feat(snippets): redesign panel to match the host list

* fix(session-recording): stop session logs panel from refetching in a loop

* feat(snippets): bring back sharing, target hosts and drag reorder

* feat(ssh-terminal): add toolbar position, start state, display and fade host settings

* feat(ssh-terminal): add host setting to turn off password prompt auto-fill

* fix(ai): stop streams getting cut off and keep tool steps across messages

* feat: improve quick connect system using plugin api

* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

* feat: move terminal settings, look and ssh tools into ssh-terminal plugin

* feat: move snippets, keybinding actions and macros out of core into plugins

* feat: store rdp, vnc and telnet logins in a generic per-protocol table

* feat: type the plugin host record, move plugin strings out of core and add lint checks

* feat: replace core homepage widget registry with generic extension points

* feat: plugins only see their own host settings in the browser

* feat: harden plugin api for separate repos, signed updates and generic external login

* fix: keep plugin css below core utilities and version plugin assets by their css

* fix: load and save plugin host settings in the editor and tighten settings row spacing

* feat: redesign user host defaults system

* fix(remote-desktop): release held keys on focus loss (#1498)

* fix(session-sharing): replace local presentation after takeover (#1500)

* fix(file-manager): refresh disk usage with directory listing (#1503)

* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)

* feat(remote-desktop): expose display zoom controls for RDP (#1514)

* fix(file-manager): restore text selection and copying in Markdown previews (#1518)

* fix(sidebar): keep compact host addresses visible (#1524)

* fix(hosts): preserve plugin settings when reopening the editor (#1499)

* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)

* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

* fix(session-sharing): show participant presence in member terminals (#1504)

* fix(ai): sync provider configuration and keys across linked devices (#1506)

* feat(ssh-terminal): add a duplicate tab action (#1510)

* feat(file-manager): allow disabling move-to-trash confirmation (#1511)

* feat(file-manager): default to the remote login directory (#1512)

* feat(homepage): synchronize canvas layouts across devices (#1515)

* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)

* feat(tmux-monitor): add a unified multi-host view (#1520)

* fix(electron): allow explicit quit with active connections (#1522)

* feat: reconnect all disconnected terminal tabs (#1523)

* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)

* fix(database): preserve RDP domain during startup backfill (#1497)

* feat(sidebar): add keyboard type-ahead search for hosts (#1516)

* feat(automations): add per-host maintenance mode and recurring schedules (#1521)

* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)

* fix(session-sharing): use the linked server for desktop meetings and links (#1507)

* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)

* fix: type errors and lint failures from merged plugin prs

* feat(installer): make the Windows desktop shortcut optional (#1517)

* fix: ship the plugin sdk and serialport in desktop builds

* fix: prefer ipv4 when resolving ssh host names (#1365)

* fix: readable terminal selection with the light theme (#1319)

* fix: build desktop session share links on the linked server (#1350)

* fix: show meeting guests to members and hide the roster from guests (#1353)

* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)

* fix: show disk usage for the mount being browsed (#1347)

* fix: load the linux tray icon reliably and log tray failures (#1332)

* fix: cover the gnome close path in the desktop quit test (#1332)

* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)

* 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: sync Crowdin translations for 2.9.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

@@ -0,0 +1,144 @@
/**
* The ACME settings migration: a 2.8 acme_ssl_settings row comes through into
* the acme-ssl plugin's admin settings, the Cloudflare token stays encrypted
* and leaves the old row, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
legacy: {} as Record<string, string>,
pluginRows: [] as Array<{
key: string;
value: string | null;
encrypted: boolean;
}>,
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "acme-ssl" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.legacy[key] ?? null,
set: async (key: string, value: string) => {
state.legacy[key] = value;
},
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => state.pluginRows.find((row) => row.key === key) ?? null,
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
state.pluginRows.push({ key, value, encrypted });
},
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `enc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runAcmeSslSettingsMigration } from "../../upgrade/acme-ssl-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.legacy = {
acme_ssl_settings: JSON.stringify({
enabled: true,
domain: "termix.example.com",
email: "admin@example.com",
challengeType: "dns-cloudflare",
cloudflareToken: "cf-token",
lastIssuedAt: "2026-01-01T00:00:00.000Z",
}),
};
state.pluginRows = [];
state.pluginInstalled = true;
});
describe("runAcmeSslSettingsMigration", () => {
it("moves every field and encrypts the token", async () => {
const result = await runAcmeSslSettingsMigration();
expect(result.moved.sort()).toEqual(
[
"autoRenew",
"challengeType",
"cloudflareToken",
"domain",
"email",
].sort(),
);
expect(value("domain")).toBe("termix.example.com");
expect(value("email")).toBe("admin@example.com");
expect(value("challengeType")).toBe("dns-cloudflare");
expect(value("autoRenew")).toBe(true);
expect(value("cloudflareToken")).toBe("enc:cf-token");
expect(
state.pluginRows.find((row) => row.key === "cloudflareToken")?.encrypted,
).toBe(true);
const left = JSON.parse(state.legacy.acme_ssl_settings);
expect(left.cloudflareToken).toBeUndefined();
expect(left.domain).toBe("termix.example.com");
});
it("maps the webroot challenge and turns renewal off for manual", async () => {
state.legacy.acme_ssl_settings = JSON.stringify({
enabled: true,
domain: "a.example",
email: "a@example.com",
challengeType: "http-webroot",
});
await runAcmeSslSettingsMigration();
expect(value("challengeType")).toBe("http-01");
expect(value("autoRenew")).toBe(true);
state.pluginRows = [];
state.legacy.acme_ssl_settings = JSON.stringify({
enabled: true,
challengeType: "manual",
});
await runAcmeSslSettingsMigration();
expect(value("autoRenew")).toBe(false);
expect(value("challengeType")).toBeUndefined();
});
it("changes nothing on a second run or over values already set", async () => {
await runAcmeSslSettingsMigration();
const snapshot = JSON.stringify(state.pluginRows);
const second = await runAcmeSslSettingsMigration();
expect(second.moved).toEqual([]);
expect(JSON.stringify(state.pluginRows)).toBe(snapshot);
});
it("does nothing before the plugin row exists or without a legacy row", async () => {
state.pluginInstalled = false;
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
state.pluginInstalled = true;
state.legacy = {};
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,257 @@
/**
* The AI settings migration.
*
* An upgrade must be lossless: the admin switch, the private endpoint list,
* every user's opt-in, every host's switch and every provider key have to
* reach the ai plugin, keys encrypted and gone from the table. Running it
* twice must not duplicate or clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const state = vi.hoisted(() => ({
pluginRows: [] as Row[],
coreSettings: new Map<string, string>(),
preferences: [] as Array<Record<string, unknown>>,
hosts: [] as Array<Record<string, unknown>>,
providers: new Map<string, Array<Record<string, unknown>>>(),
pluginInstalled: true,
deks: new Set<string>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "ai" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.coreSettings.get(key) ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows.push({
pluginId,
scope,
scopeId,
key,
value,
encrypted,
});
},
}),
}));
/** The SQL text of a drizzle sql`` query, identifiers included. */
function text(query: { queryChunks: unknown[] }): string {
return query.queryChunks
.map((chunk) => {
if (typeof chunk === "string") return chunk;
const value = (chunk as { value?: unknown; name?: unknown }) ?? {};
if (Array.isArray(value.value)) return value.value.join("");
// sql.identifier() is a Name chunk carrying the table name.
if (typeof value.value === "string") return value.value;
if (typeof value.name === "string") return value.name;
return String(chunk);
})
.join("");
}
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
if (sqlText.includes("FROM user_preferences")) return state.preferences;
if (sqlText.includes("FROM ssh_data")) return state.hosts;
for (const [table, rows] of state.providers) {
if (new RegExp(`FROM ${table} `).test(sqlText)) {
return rows.filter((row) => row.api_key);
}
}
throw new Error(`no such table in: ${sqlText}`);
},
runStatement: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.providers) {
if (!sqlText.includes(`UPDATE ${table} `)) continue;
const id = query.queryChunks.find((chunk) => typeof chunk === "number");
for (const row of rows) if (row.id === id) row.api_key = null;
}
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) =>
state.deks.has(userId) ? Buffer.from(userId) : null,
},
}));
vi.mock("../../utils/lazy-field-encryption.js", () => ({
LazyFieldEncryption: {
// Stands in for FieldCrypto: "enc:<plain>" decrypts, anything else is a
// legacy plaintext value.
safeGetFieldValue: (value: string) => value.replace(/^enc:/, ""),
},
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
allowlistToText,
runAiSettingsMigration,
} from "../../upgrade/ai-settings-migration.js";
const value = (scope: string, scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
(entry) =>
entry.scope === scope && entry.scopeId === scopeId && entry.key === key,
);
return row ? JSON.parse(row.value ?? "null") : undefined;
};
beforeEach(() => {
state.pluginRows = [];
state.coreSettings = new Map([
["ai_globally_enabled", "true"],
["ai_private_endpoint_allowlist", '["localhost", "ollama.lan"]'],
]);
state.preferences = [
{ user_id: "u1", ai_assistant_enabled: 1, ai_read_only_commands: 0 },
{ user_id: "u2", ai_assistant_enabled: null, ai_read_only_commands: null },
];
state.hosts = [
{ id: 5, enable_ai_assistant: 1 },
{ id: 6, enable_ai_assistant: 0 },
];
state.providers = new Map([
[
"ai_providers",
[
{ id: 3, user_id: "u1", api_key: "enc:sk-one" },
{ id: 4, user_id: "u2", api_key: "sk-plain" },
],
],
]);
state.pluginInstalled = true;
state.deks = new Set(["u1", "u2"]);
});
describe("allowlistToText", () => {
it("turns the stored JSON array into one host per line", () => {
expect(allowlistToText('[" a ", "", "b"]')).toBe("a\nb");
expect(allowlistToText("[]")).toBe("");
expect(allowlistToText("nope")).toBeNull();
});
});
describe("runAiSettingsMigration", () => {
it("moves admin, user and host settings", async () => {
await runAiSettingsMigration();
expect(value("admin", null, "globallyEnabled")).toBe(true);
expect(value("admin", null, "privateEndpoints")).toBe(
"localhost\nollama.lan",
);
expect(value("user", "u1", "enabled")).toBe(true);
expect(value("user", "u1", "allowReadOnlyCommands")).toBe(false);
// Never asked stays unset, which the plugin reads as not enabled.
expect(value("user", "u2", "enabled")).toBeUndefined();
expect(value("host", "5", "enableAiAssistant")).toBe(true);
expect(value("host", "6", "enableAiAssistant")).toBeUndefined();
});
it("moves each provider key into its owner's secrets, encrypted, and clears the column", async () => {
await runAiSettingsMigration();
const secret = state.pluginRows.find(
(row) => row.scope === "secret" && row.key === "provider:3",
);
expect(secret).toMatchObject({
scopeId: "u1",
encrypted: true,
value: JSON.stringify("sysenc:sk-one"),
});
expect(value("secret", "u2", "provider:4")).toBe("sysenc:sk-plain");
expect(
state.providers.get("ai_providers")!.every((row) => row.api_key === null),
).toBe(true);
});
it("leaves a key alone while its owner's data key is unavailable", async () => {
state.deks = new Set(["u1"]);
await runAiSettingsMigration();
expect(value("secret", "u2", "provider:4")).toBeUndefined();
const row = state.providers.get("ai_providers")!.find((r) => r.id === 4);
expect(row?.api_key).toBe("sk-plain");
});
it("reads the adopted table once the plugin has renamed it", async () => {
state.providers = new Map([
["p_ai_providers", [{ id: 8, user_id: "u1", api_key: "enc:sk-late" }]],
]);
await runAiSettingsMigration();
expect(value("secret", "u1", "provider:8")).toBe("sysenc:sk-late");
});
it("changes nothing on a second run", async () => {
await runAiSettingsMigration();
const first = JSON.stringify(state.pluginRows);
await runAiSettingsMigration();
expect(JSON.stringify(state.pluginRows)).toBe(first);
});
it("does not overwrite a value the plugin already has", async () => {
state.pluginRows.push({
pluginId: "ai",
scope: "admin",
scopeId: null,
key: "globallyEnabled",
value: "false",
encrypted: false,
});
await runAiSettingsMigration();
expect(value("admin", null, "globallyEnabled")).toBe(false);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
await runAiSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,197 @@
/**
* The Docker host settings migration.
*
* An upgrade must be lossless: every host with Docker on, and every host on
* Podman, must come through into the docker plugin's host settings, whatever
* shape the engine returned the flag in. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
pluginInstalled: true,
columnsGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "docker" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
);
if (existing) {
existing.value = value;
return;
}
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async () => {
if (state.columnsGone) throw new Error("no such column: enable_docker");
return state.hostRows;
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
dockerSettingsFromRow,
runDockerSettingsMigration,
} from "../../upgrade/docker-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
state.pluginRows
.filter((row) => row.scope === "host" && row.scopeId === String(hostId))
.map((row) => [row.key, JSON.parse(row.value ?? "null")]),
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.pluginInstalled = true;
state.columnsGone = false;
});
describe("dockerSettingsFromRow", () => {
it("reads SQLite integers, real booleans and strings", () => {
expect(
dockerSettingsFromRow({ id: 1, enable_docker: 1, docker_config: null }),
).toEqual({ enableDocker: true });
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: true,
docker_config: null,
}),
).toEqual({ enableDocker: true });
expect(
dockerSettingsFromRow({ id: 1, enable_docker: 0, docker_config: null }),
).toEqual({});
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: false,
docker_config: null,
}),
).toEqual({});
});
it("keeps a Podman runtime and ignores the default or broken config", () => {
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: 1,
docker_config: '{"runtime":"podman"}',
}),
).toEqual({ enableDocker: true, containerRuntime: "podman" });
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: 1,
docker_config: '{"runtime":"docker"}',
}),
).toEqual({ enableDocker: true });
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: 1,
docker_config: "{oops",
}),
).toEqual({ enableDocker: true });
});
});
describe("runDockerSettingsMigration", () => {
it("moves every host's docker options and skips hosts with nothing set", async () => {
state.hostRows = [
{ id: 1, enable_docker: 1, docker_config: null },
{ id: 2, enable_docker: 1, docker_config: '{"runtime":"podman"}' },
{ id: 3, enable_docker: 0, docker_config: null },
];
const result = await runDockerSettingsMigration();
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0 });
expect(hostValues(1)).toEqual({ enableDocker: true });
expect(hostValues(2)).toEqual({
enableDocker: true,
containerRuntime: "podman",
});
expect(hostValues(3)).toEqual({});
});
it("changes nothing when run twice", async () => {
state.hostRows = [
{ id: 1, enable_docker: 1, docker_config: '{"runtime":"podman"}' },
];
await runDockerSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const second = await runDockerSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1 });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("keeps a value already saved in the plugin", async () => {
state.pluginRows.push({
pluginId: "docker",
scope: "host",
scopeId: "1",
key: "enableDocker",
value: "false",
});
state.hostRows = [{ id: 1, enable_docker: 1, docker_config: null }];
await runDockerSettingsMigration();
expect(hostValues(1)).toEqual({ enableDocker: false });
});
it("does nothing before the plugin row exists or once the columns are gone", async () => {
state.hostRows = [{ id: 1, enable_docker: 1, docker_config: null }];
state.pluginInstalled = false;
expect(await runDockerSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
state.pluginInstalled = true;
state.columnsGone = true;
expect(await runDockerSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAuthState, type AuthState } from "../auth/auth-test-helpers.js";
const h = vi.hoisted(() => ({ state: null as unknown as AuthState }));
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("../auth/auth-test-helpers.js");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
});
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const { runExternalIdentityMigration, parseLegacyIdentifier } =
await import("../../upgrade/external-identity-migration.js");
function user(id: string, extra: Record<string, unknown>) {
h.state.users.set(id, {
id,
username: id,
passwordHash: "",
isAdmin: false,
isOidc: true,
...extra,
});
}
beforeEach(() => {
h.state = createAuthState();
});
describe("parseLegacyIdentifier", () => {
it("splits the prefixed forms and keeps bare subjects on their provider", () => {
expect(parseLegacyIdentifier("ldap:4:bob", null)).toEqual({
providerId: "ldap:4",
subject: "bob",
});
expect(parseLegacyIdentifier("github:7:42", null)).toEqual({
providerId: "7",
subject: "42",
});
expect(parseLegacyIdentifier("ldap:4:cn=a:b", null)).toEqual({
providerId: "ldap:4",
subject: "cn=a:b",
});
expect(parseLegacyIdentifier("sub-123", 3)).toEqual({
providerId: "3",
subject: "sub-123",
});
expect(parseLegacyIdentifier("sub-123", null)).toEqual({
providerId: "legacy-oidc",
subject: "sub-123",
});
expect(parseLegacyIdentifier("github:null:42", null)).toEqual({
providerId: "legacy-oidc",
subject: "42",
});
});
});
describe("runExternalIdentityMigration", () => {
it("moves every identifier without touching the old data", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob", ssoProviderId: 4 });
user("gh-user", { oidcIdentifier: "github:7:42", ssoProviderId: 7 });
user("oidc-user", {
oidcIdentifier: "sub-123",
ssoProviderId: 3,
});
user("env-user", { oidcIdentifier: "someone@example.com" });
user("local", { isOidc: false, passwordHash: "hash" });
const result = await runExternalIdentityMigration();
expect(result).toEqual({ identities: 4, skipped: 0 });
expect(
h.state.identities.map(({ userId, providerId, subject }) => ({
userId,
providerId,
subject,
})),
).toEqual([
{ userId: "ldap-user", providerId: "ldap:4", subject: "bob" },
{ userId: "gh-user", providerId: "7", subject: "42" },
{ userId: "oidc-user", providerId: "3", subject: "sub-123" },
{
userId: "env-user",
providerId: "legacy-oidc",
subject: "someone@example.com",
},
]);
// Lossless: the old column stays.
expect(h.state.users.get("ldap-user")!.oidcIdentifier).toBe("ldap:4:bob");
});
it("is idempotent", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob" });
await runExternalIdentityMigration();
const second = await runExternalIdentityMigration();
expect(second).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
});
});
@@ -0,0 +1,163 @@
/**
* The file-manager host-columns migration.
*
* An upgrade must be lossless: a host that had file manager disabled, a
* custom default path, or SCP legacy mode on must keep that configuration
* once the ssh_data columns are dropped. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_file_manager: boolean;
default_path: string | null;
scp_legacy: boolean;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_file_manager === false ||
row.default_path !== null ||
row.scp_legacy,
),
}),
}));
const { runFileManagerSettingsMigration } =
await import("../../upgrade/file-manager-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runFileManagerSettingsMigration", () => {
it("moves a host with file manager disabled into plugin settings", async () => {
hostRows.push({
id: 1,
enable_file_manager: false,
default_path: "/srv/app",
scp_legacy: true,
});
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableFileManager")).toBe(false);
expect(storedValue(1, "defaultPath")).toBe("/srv/app");
expect(storedValue(1, "scpLegacy")).toBe(true);
});
it("does nothing on a fresh install with no non-default hosts", async () => {
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({
id: 2,
enable_file_manager: false,
default_path: null,
scp_legacy: false,
});
await runFileManagerSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runFileManagerSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("moves each host independently", async () => {
hostRows.push(
{
id: 1,
enable_file_manager: false,
default_path: null,
scp_legacy: false,
},
{
id: 2,
enable_file_manager: true,
default_path: "/data",
scp_legacy: false,
},
);
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(2);
expect(storedValue(1, "enableFileManager")).toBe(false);
expect(storedValue(2, "defaultPath")).toBe("/data");
});
});
@@ -0,0 +1,311 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type PluginRow = {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
};
const db = vi.hoisted(() => ({
settings: {} as Record<string, string>,
pluginRows: [] as PluginRow[],
defaults: [] as Array<{
scope: Record<string, unknown>;
namespace: string;
key: string;
value: string;
}>,
hosts: [] as Array<Record<string, unknown>>,
folders: [] as Array<{ id: number; userId: string; credentialId: number }>,
folderCredential: new Map<string, number>(),
}));
const manifests = vi.hoisted(() => [
{
id: "ssh-terminal",
contributes: {
settings: {
host: {
fields: [
{ key: "fontSize", type: "number", default: 14 },
{ key: "theme", type: "string", default: "termix" },
{ key: "localEcho", type: "select", default: "auto" },
{ key: "linkClickBehavior", type: "select", default: "confirm" },
],
},
},
},
},
{
id: "remote-desktop",
contributes: {
settings: {
host: {
fields: [{ key: "colorDepth", type: "select", default: "inherit" }],
},
},
},
},
{ id: "host-metrics", contributes: { settings: { host: { fields: [] } } } },
]);
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => manifests,
}));
vi.mock("../../database/db/schema.js", () => ({
sshFolders: { id: "id", userId: "userId", credentialId: "credentialId" },
}));
vi.mock("../../database/repositories/factory.js", () => {
const scopeKey = (scope: Record<string, unknown>) =>
scope.level === "admin"
? "admin"
: scope.level === "user"
? `u:${scope.userId}`
: `f:${scope.folderId}`;
return {
createCurrentSettingsRepository: () => ({
get: async (key: string) => db.settings[key] ?? null,
set: async (key: string, value: string) => {
db.settings[key] = value;
},
}),
createCurrentPluginSettingsRepository: () => ({
listByKey: async (pluginId: string, scope: string, key: string) =>
db.pluginRows.filter(
(row) =>
row.pluginId === pluginId && row.scope === scope && row.key === key,
),
getAll: async (pluginId: string, scope: string, scopeId: string) =>
db.pluginRows.filter(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
value: string,
) => {
db.pluginRows = db.pluginRows.filter(
(row) =>
!(
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key
),
);
db.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
delete: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
) => {
db.pluginRows = db.pluginRows.filter(
(row) =>
!(
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key
),
);
},
}),
createCurrentHostDefaultsRepository: () => ({
listScope: async (scope: Record<string, unknown>) =>
db.defaults.filter((row) => scopeKey(row.scope) === scopeKey(scope)),
apply: async (
scope: Record<string, unknown>,
set: Array<{ namespace: string; key: string; value: string }>,
) => {
for (const entry of set) db.defaults.push({ scope, ...entry });
},
listHosts: async () => db.hosts,
updateHosts: async (
patches: Array<{ id: number; values: Record<string, unknown> }>,
) => {
for (const patch of patches) {
Object.assign(
db.hosts.find((host) => host.id === patch.id)!,
patch.values,
);
}
},
}),
createCurrentHostResolutionRepository: () => ({
findFolderCredentialId: async (_userId: string, folder: string) =>
db.folderCredential.get(folder) ?? null,
}),
createCurrentRepositoryContext: () => ({
drizzle: {
select: () => ({
from: () => ({ where: async () => db.folders }),
}),
},
}),
};
});
const { runHostDefaultsMigration } =
await import("../../upgrade/host-defaults-migration.js");
function level(namespace: string, key: string, scopeLevel: string) {
const row = db.defaults.find(
(entry) =>
entry.namespace === namespace &&
entry.key === key &&
entry.scope.level === scopeLevel,
);
return row ? JSON.parse(row.value) : undefined;
}
function hostValue(hostId: number, pluginId: string, key: string) {
const row = db.pluginRows.find(
(entry) =>
entry.pluginId === pluginId &&
entry.scope === "host" &&
entry.scopeId === String(hostId) &&
entry.key === key,
);
return row?.value ? JSON.parse(row.value) : undefined;
}
const plugin = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: unknown,
): PluginRow => ({
pluginId,
scope,
scopeId,
key,
value: JSON.stringify(value),
});
beforeEach(() => {
db.settings = {};
db.pluginRows = [];
db.defaults = [];
db.hosts = [];
db.folders = [];
db.folderCredential = new Map();
});
describe("runHostDefaultsMigration", () => {
it("moves the old server defaults, dropping the credential and password", async () => {
db.settings.host_defaults = JSON.stringify({
useSocks5: true,
socks5Host: "proxy",
socks5Port: 1081,
socks5Password: "secret",
credentialId: 3,
statusCheckEnabled: false,
});
db.pluginRows.push(
plugin("ssh-terminal", "admin", null, "newHostFontSize", 18),
plugin("host-metrics", "admin", null, "enabledForNewHosts", false),
);
db.folders = [{ id: 4, userId: "u1", credentialId: 9 }];
await runHostDefaultsMigration();
expect(level("core", "socks5", "admin")).toMatchObject({
useSocks5: true,
socks5Host: "proxy",
socks5Port: 1081,
});
expect(JSON.stringify(db.defaults)).not.toContain("secret");
expect(level("core", "statusCheckEnabled", "admin")).toBe(false);
expect(level("ssh-terminal", "fontSize", "admin")).toBe(18);
expect(level("host-metrics", "metricsEnabled", "admin")).toBe(false);
expect(level("core", "auth", "folder")).toMatchObject({
authType: "credential",
credentialId: 9,
});
});
it("moves each user's terminal and remote desktop defaults to their own level", async () => {
db.pluginRows.push(
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
fontSize: 20,
}),
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
plugin("remote-desktop", "user", "u1", "colorDepth", "16"),
plugin("remote-desktop", "user", "u1", "disableAudio", "inherit"),
);
await runHostDefaultsMigration();
expect(level("ssh-terminal", "fontSize", "user")).toBe(20);
expect(level("ssh-terminal", "localEcho", "user")).toBe("on");
expect(level("remote-desktop", "colorDepth", "user")).toBe("16");
expect(level("remote-desktop", "disableAudio", "user")).toBeUndefined();
});
it("writes in the look a host really had while it followed its user", async () => {
db.hosts = [
{ id: 1, userId: "u1", defaultOverrides: null },
{ id: 2, userId: "u1", defaultOverrides: null },
{ id: 3, userId: "u1", defaultOverrides: '{"core":[]}' },
];
db.pluginRows.push(
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
fontSize: 20,
}),
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
plugin("ssh-terminal", "host", "1", "fontSize", 30),
plugin("ssh-terminal", "host", "1", "localEcho", "default"),
plugin("ssh-terminal", "host", "2", "inheritAppearance", false),
plugin("ssh-terminal", "host", "2", "fontSize", 30),
plugin("ssh-terminal", "host", "2", "localEcho", "off"),
plugin("remote-desktop", "user", "u1", "colorDepth", "24"),
);
await runHostDefaultsMigration();
// Followed the user: the user's look, and their echo mode.
expect(hostValue(1, "ssh-terminal", "fontSize")).toBe(20);
expect(hostValue(1, "ssh-terminal", "localEcho")).toBe("on");
expect(hostValue(1, "remote-desktop", "colorDepth")).toBe("24");
// Had its own look: kept.
expect(hostValue(2, "ssh-terminal", "fontSize")).toBe(30);
expect(hostValue(2, "ssh-terminal", "localEcho")).toBe("off");
// Saved since host defaults: left alone.
expect(hostValue(3, "ssh-terminal", "fontSize")).toBeUndefined();
});
it("gives a credential host its folder's credential", async () => {
db.hosts = [
{
id: 1,
userId: "u1",
authType: "credential",
credentialId: null,
folder: "Prod",
defaultOverrides: null,
},
];
db.folderCredential.set("Prod", 12);
await runHostDefaultsMigration();
expect(db.hosts[0].credentialId).toBe(12);
});
it("moves each level once", async () => {
db.settings.host_defaults = JSON.stringify({ statusCheckEnabled: false });
await runHostDefaultsMigration();
db.defaults = [];
await runHostDefaultsMigration();
expect(level("core", "statusCheckEnabled", "admin")).toBeUndefined();
});
});
@@ -0,0 +1,200 @@
/**
* Host Metrics' settings leaving core: the two admin keys, the new-host
* default inside host_defaults, and the metrics half of every host's
* stats_config. Nothing may be lost, and a second run must change nothing.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const state = vi.hoisted(() => ({
coreSettings: new Map<string, string>(),
pluginRows: [] as Array<{
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
pluginInstalled: true,
db: null as unknown,
}));
const find = (scope: string, scopeId: string | null, key: string) =>
state.pluginRows.find(
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
);
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "host-metrics" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(scope, scopeId, key);
if (existing) existing.value = value;
else state.pluginRows.push({ scope, scopeId, key, value });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const { runHostMetricsSettingsMigration, hostSettingsFromStatsConfig } =
await import("../../upgrade/host-metrics-settings-migration.js");
let sqlite: Database.Database;
const value = (scope: string, scopeId: string | null, key: string) => {
const row = find(scope, scopeId, key) as Row | undefined;
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.coreSettings.clear();
state.pluginRows = [];
state.pluginInstalled = true;
sqlite = new Database(":memory:");
sqlite.exec(
"CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, stats_config TEXT)",
);
state.db = drizzle(sqlite);
});
describe("hostSettingsFromStatsConfig", () => {
it("keeps only what differs from the defaults", () => {
expect(
hostSettingsFromStatsConfig(
JSON.stringify({
statusCheckEnabled: false,
metricsEnabled: false,
metricsInterval: 45,
useGlobalMetricsInterval: false,
enabledWidgets: ["cpu", "disk"],
excludedMounts: ["/snap"],
monitoredMounts: [{ path: "/data", label: "Data" }, { bad: 1 }],
}),
),
).toEqual({
metricsEnabled: false,
metricsInterval: 45,
enabledWidgets: ["cpu", "disk"],
excludedMounts: ["/snap"],
monitoredMounts: [{ path: "/data", label: "Data" }],
});
});
it("ignores the host's interval while it followed the global one", () => {
expect(
hostSettingsFromStatsConfig(JSON.stringify({ metricsInterval: 45 })),
).toEqual({});
});
it("reads a value that was stringified twice", () => {
expect(
hostSettingsFromStatsConfig(
JSON.stringify(JSON.stringify({ metricsEnabled: false })),
),
).toEqual({ metricsEnabled: false });
});
});
describe("runHostMetricsSettingsMigration", () => {
it("moves the admin keys, the new-host default and every host", async () => {
state.coreSettings.set("global_metrics_interval", "20");
state.coreSettings.set("metrics_history_retention_days", "30");
state.coreSettings.set(
"host_defaults",
JSON.stringify({ metricsEnabled: false, statusCheckEnabled: true }),
);
const insert = sqlite.prepare(
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
);
insert.run(1, JSON.stringify({ metricsEnabled: false }));
insert.run(2, JSON.stringify({ enabledWidgets: ["cpu"] }));
insert.run(3, JSON.stringify({ statusCheckEnabled: false }));
insert.run(4, null);
const result = await runHostMetricsSettingsMigration();
expect(result.moved.sort()).toEqual([
"global_metrics_interval",
"host_defaults.metricsEnabled",
"metrics_history_retention_days",
]);
expect(value("admin", null, "metricsInterval")).toBe(20);
expect(value("admin", null, "historyRetentionDays")).toBe(30);
expect(value("admin", null, "enabledForNewHosts")).toBe(false);
expect(value("host", "1", "metricsEnabled")).toBe(false);
expect(value("host", "2", "enabledWidgets")).toEqual(["cpu"]);
// Only the status half, which is core's: nothing for the plugin.
expect(state.pluginRows.filter((row) => row.scopeId === "3")).toEqual([]);
expect(result.hostsMoved).toBe(2);
});
it("changes nothing the second time", async () => {
state.coreSettings.set("global_metrics_interval", "20");
sqlite
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
.run(1, JSON.stringify({ metricsEnabled: false }));
await runHostMetricsSettingsMigration();
// Changed through the plugin after the upgrade.
const interval = find("admin", null, "metricsInterval")!;
interval.value = "90";
const enabled = find("host", "1", "metricsEnabled")!;
enabled.value = "true";
const second = await runHostMetricsSettingsMigration();
expect(second).toEqual({ moved: [], hostsMoved: 0, hostsSkipped: 1 });
expect(value("admin", null, "metricsInterval")).toBe(90);
expect(value("host", "1", "metricsEnabled")).toBe(true);
});
it("waits until the plugin row exists", async () => {
state.pluginInstalled = false;
state.coreSettings.set("global_metrics_interval", "20");
expect(await runHostMetricsSettingsMigration()).toEqual({
moved: [],
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
it("skips values out of range", async () => {
state.coreSettings.set("global_metrics_interval", "1");
state.coreSettings.set("metrics_history_retention_days", "900");
await runHostMetricsSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,179 @@
/**
* The 2.8 to 2.9 LDAP split. LDAP rows in sso_providers (adopted or not yet)
* move into the ldap plugin's table with their ids, their identities carry
* the "ldap:" prefix, the SSO rows stay, and running it twice changes nothing.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({ db: null as unknown }));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runLdapProviderMigration } =
await import("../../upgrade/ldap-provider-migration.js");
let sqlite: Database.Database;
const LEGACY_DDL = `
CREATE TABLE IF NOT EXISTS sso_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
const TARGET_DDL = `
CREATE TABLE IF NOT EXISTS "p_ldap_providers" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"name" text NOT NULL,
"enabled" integer NOT NULL DEFAULT 1,
"display_order" integer NOT NULL DEFAULT 0,
"config" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
function setup(options: { adopted: boolean; target: boolean }) {
sqlite = new Database(":memory:");
sqlite.exec(`
${LEGACY_DDL}
CREATE TABLE user_external_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
subject TEXT NOT NULL,
email TEXT
);
`);
const insert = sqlite.prepare(
"INSERT INTO sso_providers (id, name, type, enabled, config) VALUES (?, ?, ?, ?, ?)",
);
insert.run(3, "Keycloak", "oidc", 1, '{"client_id":"termix"}');
insert.run(4, "Corp LDAP", "ldap", 1, '{"host":"ldap.example"}');
insert.run(5, "Old LDAP", "ldap", 0, '{"host":"old.example"}');
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-oidc", "3", "sub-1");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-ldap", "ldap:4", "bob");
if (options.adopted) {
sqlite.exec(`ALTER TABLE sso_providers RENAME TO p_sso_providers;`);
}
if (options.target) sqlite.exec(TARGET_DDL);
h.db = drizzle(sqlite);
}
const all = (query: string) => sqlite.prepare(query).all();
describe("runLdapProviderMigration", () => {
beforeEach(() => {
vi.unstubAllEnvs();
});
it("does nothing until the ldap plugin's table exists", async () => {
setup({ adopted: false, target: false });
expect(await runLdapProviderMigration()).toEqual({
moved: 0,
renumbered: 0,
});
expect(all("SELECT id FROM sso_providers")).toHaveLength(3);
});
it.each([
["the legacy table", false],
["the adopted table", true],
])("moves LDAP rows out of %s with their ids", async (_label, adopted) => {
setup({ adopted, target: true });
const source = adopted ? "p_sso_providers" : "sso_providers";
expect(await runLdapProviderMigration()).toEqual({
moved: 2,
renumbered: 0,
});
expect(
all("SELECT id, name, enabled, config FROM p_ldap_providers ORDER BY id"),
).toEqual([
{
id: 4,
name: "Corp LDAP",
enabled: 1,
config: '{"host":"ldap.example"}',
},
{ id: 5, name: "Old LDAP", enabled: 0, config: '{"host":"old.example"}' },
]);
expect(all(`SELECT id, type FROM ${source}`)).toEqual([
{ id: 3, type: "oidc" },
]);
expect(
all("SELECT user_id, provider_id FROM user_external_identities"),
).toEqual([
{ user_id: "u-oidc", provider_id: "3" },
{ user_id: "u-ldap", provider_id: "ldap:4" },
]);
});
it("renames bare identities left from before the prefix", async () => {
setup({ adopted: true, target: true });
sqlite.exec(
"UPDATE user_external_identities SET provider_id = '4' WHERE user_id = 'u-ldap'",
);
await runLdapProviderMigration();
expect(
all(
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-ldap'",
),
).toEqual([{ provider_id: "ldap:4" }]);
});
it("gives a row a new id when the plugin already used it", async () => {
setup({ adopted: true, target: true });
sqlite.exec(
`INSERT INTO p_ldap_providers (id, name, config) VALUES (4, 'New', '{}')`,
);
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-bare", "4", "carol");
const result = await runLdapProviderMigration();
expect(result).toEqual({ moved: 2, renumbered: 1 });
const moved = all(
"SELECT id FROM p_ldap_providers WHERE name = 'Corp LDAP'",
) as Array<{ id: number }>;
expect(moved[0].id).not.toBe(4);
expect(
all(
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-bare'",
),
).toEqual([{ provider_id: `ldap:${moved[0].id}` }]);
});
it("is idempotent", async () => {
setup({ adopted: true, target: true });
await runLdapProviderMigration();
expect(await runLdapProviderMigration()).toEqual({
moved: 0,
renumbered: 0,
});
expect(all("SELECT id FROM p_ldap_providers")).toHaveLength(2);
});
});
@@ -0,0 +1,223 @@
/**
* The 2.8 to 2.9 channel move. Every notification channel reaches the alerts
* plugin's table with its id, its config sealed with the installation key once
* the owner's data key opens, and nothing left readable in the old table.
* A closed or wrong key leaves the config as it was, and a later run for that
* user finishes the job. Running it twice changes nothing.
*/
import crypto from "node:crypto";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FieldCrypto } from "../../utils/field-crypto.js";
const h = vi.hoisted(() => ({
db: null as unknown,
deks: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:v1:${value}`,
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runNotificationChannelMigration } =
await import("../../upgrade/notification-channel-migration.js");
let sqlite: Database.Database;
const SOURCE_DDL = `
CREATE TABLE notification_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
const TARGET_DDL = `
CREATE TABLE "p_alerts_channels" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"config" text,
"enabled" integer NOT NULL DEFAULT 1,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
function setup({ target }: { target: boolean }) {
sqlite = new Database(":memory:");
sqlite.exec(SOURCE_DDL);
if (target) sqlite.exec(TARGET_DDL);
h.db = drizzle(sqlite);
h.deks.clear();
}
function addChannel(
id: number,
userId: string,
config: Record<string, unknown>,
options: { encrypt?: boolean; enabled?: boolean } = {},
) {
let dek = h.deks.get(userId);
if (!dek) {
dek = crypto.randomBytes(32);
h.deks.set(userId, dek);
}
const text = JSON.stringify(config);
const stored =
options.encrypt === false
? text
: FieldCrypto.encryptField(text, dek, String(id), "config");
sqlite
.prepare(
"INSERT INTO notification_channels (id, user_id, name, type, config, enabled) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(
id,
userId,
`channel-${id}`,
"ntfy",
stored,
options.enabled === false ? 0 : 1,
);
}
const targetRows = () =>
sqlite
.prepare(
'SELECT id, user_id, name, type, config, enabled FROM "p_alerts_channels" ORDER BY id',
)
.all() as Array<{
id: number;
user_id: string;
name: string;
type: string;
config: string;
enabled: number;
}>;
const sealed = (config: Record<string, unknown>) =>
`sysenc:v1:${JSON.stringify(config)}`;
describe("runNotificationChannelMigration", () => {
beforeEach(() => setup({ target: true }));
it("does nothing until the plugin's table exists", async () => {
setup({ target: false });
addChannel(1, "alice", { url: "https://ntfy.sh", topic: "a" });
expect(await runNotificationChannelMigration()).toEqual({
moved: 0,
sealed: 0,
});
});
it("copies every channel with its id and seals its config", async () => {
addChannel(4, "alice", { url: "https://ntfy.sh", topic: "a", token: "tk" });
addChannel(7, "bob", { url: "https://hooks.example" }, { enabled: false });
expect(await runNotificationChannelMigration()).toEqual({
moved: 2,
sealed: 2,
});
expect(targetRows()).toEqual([
{
id: 4,
user_id: "alice",
name: "channel-4",
type: "ntfy",
config: sealed({ url: "https://ntfy.sh", topic: "a", token: "tk" }),
enabled: 1,
},
{
id: 7,
user_id: "bob",
name: "channel-7",
type: "ntfy",
config: sealed({ url: "https://hooks.example" }),
enabled: 0,
},
]);
// No token is left readable in the old table.
expect(
sqlite.prepare("SELECT config FROM notification_channels").all(),
).toEqual([{ config: "" }, { config: "" }]);
});
it("seals a config 2.8 never encrypted", async () => {
addChannel(
2,
"alice",
{ url: "https://ntfy.sh", topic: "p" },
{
encrypt: false,
},
);
await runNotificationChannelMigration();
expect(targetRows()[0].config).toBe(
sealed({ url: "https://ntfy.sh", topic: "p" }),
);
});
it("waits for a closed key, then seals at that user's login", async () => {
addChannel(3, "carol", { url: "https://ntfy.sh", topic: "c" });
const dek = h.deks.get("carol")!;
h.deks.delete("carol");
expect(await runNotificationChannelMigration()).toEqual({
moved: 1,
sealed: 0,
});
const raw = targetRows()[0].config;
expect(raw.startsWith("sysenc:")).toBe(false);
expect(FieldCrypto.isEncrypted(raw)).toBe(true);
h.deks.set("carol", dek);
expect(await runNotificationChannelMigration("carol")).toEqual({
moved: 0,
sealed: 1,
});
expect(targetRows()[0].config).toBe(
sealed({ url: "https://ntfy.sh", topic: "c" }),
);
});
it("leaves a config the key cannot open as it was", async () => {
addChannel(5, "dave", { url: "https://ntfy.sh", topic: "d" });
h.deks.set("dave", crypto.randomBytes(32));
await runNotificationChannelMigration();
const raw = targetRows()[0].config;
expect(raw.startsWith("sysenc:")).toBe(false);
expect(FieldCrypto.isEncrypted(raw)).toBe(true);
});
it("changes nothing the second time", async () => {
addChannel(1, "alice", { url: "https://ntfy.sh", topic: "a" });
await runNotificationChannelMigration();
const first = targetRows();
expect(await runNotificationChannelMigration()).toEqual({
moved: 0,
sealed: 0,
});
expect(targetRows()).toEqual(first);
});
});
@@ -0,0 +1,96 @@
/**
* The OPKSSH config move. A 2.8 install's config must reach the opkssh
* plugin's folder, the old file must stay for a downgrade, and the install
* must keep the redirect URI its identity providers know. Running it twice
* must not overwrite anything.
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
settings: new Map<string, string | null>(),
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "opkssh" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (_p: string, _s: string, _id: string | null, key: string) =>
state.settings.has(key) ? { key, value: state.settings.get(key) } : null,
set: async (
_p: string,
_s: string,
_id: string | null,
key: string,
value: string | null,
) => {
state.settings.set(key, value);
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runOpksshConfigMigration } from "../../upgrade/opkssh-config-migration.js";
let dataDir: string;
const legacy = () => path.join(dataDir, ".opk", "config.yml");
const target = () => path.join(dataDir, "plugin-data", "opkssh", "config.yml");
beforeEach(async () => {
state.settings = new Map();
state.pluginInstalled = true;
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opkssh-config-"));
});
afterEach(async () => {
await fs.rm(dataDir, { recursive: true, force: true });
});
describe("runOpksshConfigMigration", () => {
it("copies the config, keeps the old one and the old redirect URI, once", async () => {
await fs.mkdir(path.dirname(legacy()), { recursive: true });
await fs.writeFile(legacy(), "providers:\n - alias: google\n");
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: true,
legacyCallback: true,
});
expect(await fs.readFile(target(), "utf8")).toContain("alias: google");
expect(await fs.readFile(legacy(), "utf8")).toContain("alias: google");
expect(state.settings.get("legacyCallback")).toBe("true");
await fs.writeFile(target(), "providers:\n - alias: edited\n");
state.settings.set("legacyCallback", "false");
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: false,
legacyCallback: false,
});
expect(await fs.readFile(target(), "utf8")).toContain("alias: edited");
expect(state.settings.get("legacyCallback")).toBe("false");
});
it("does nothing on an install that never set up OPKSSH", async () => {
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: false,
legacyCallback: false,
});
expect(state.settings.size).toBe(0);
});
it("waits for the plugin row", async () => {
state.pluginInstalled = false;
await fs.mkdir(path.dirname(legacy()), { recursive: true });
await fs.writeFile(legacy(), "providers: []\n");
await runOpksshConfigMigration(dataDir);
await expect(fs.access(target())).rejects.toThrow();
});
});
@@ -0,0 +1,264 @@
/**
* The 2.8 RDP, VNC and Telnet logins move out of the ssh_data columns into
* host_protocol_auth. Every shape a 2.8 install could hold must come across
* (encrypted under the column's name, the blob 2.8 copied from `password`,
* plaintext, credential mode, the pre-protocol-columns hosts), a second run
* must change nothing, and a login the user removed afterwards must not come
* back. Runs on SQLite, or on TEST_DIALECT's server.
*/
import crypto from "node:crypto";
import { sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "../database/repositories/test-support.js";
const state = vi.hoisted(() => ({
db: null as null | { drizzle: unknown },
keys: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => state.db!.drizzle,
getSqlite: () => null,
}));
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) => state.keys.get(userId) ?? null,
},
}));
import {
copiedMarker,
legacyLogin,
runProtocolAuthMigration,
} from "../../upgrade/protocol-auth-migration.js";
import { createCurrentHostProtocolAuthRepository } from "../../database/repositories/factory.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
const ownerKey = crypto.randomBytes(32);
let database: TestSqliteDatabase | null = null;
function sealed(value: string, hostId: number, field: string): string {
return FieldCrypto.encryptField(value, ownerKey, String(hostId), field);
}
function quote(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
beforeEach(async () => {
database = new TestSqliteDatabase();
state.db = await database.connect();
state.keys = new Map([["owner", ownerKey]]);
await database.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('owner', 'owner', 'x'), ('locked', 'locked', 'x');
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
(5, 'owner', 'vnc-cred', 'viewer', 'password');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
rdp_user, rdp_password, rdp_domain, rdp_auth_type)
VALUES (1, 'owner', 'ssh', 'win', '10.0.0.1', 22, 'root', 'password',
'admin', ${quote(sealed("rdp-pass", 1, "rdpPassword"))}, 'CORP', 'direct');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
vnc_auth_type, vnc_credential_id)
VALUES (2, 'owner', 'ssh', 'vnc', '10.0.0.2', 22, 'root', 'password', 'credential', 5);
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
telnet_user, telnet_password)
VALUES (3, 'owner', 'ssh', 'switch', '10.0.0.3', 22, 'root', 'password', 'tu', 'tel-plain');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, password,
domain, auth_type)
VALUES (4, 'owner', 'rdp', 'old', '10.0.0.4', 3389, 'old-user',
${quote(sealed("old-pass", 4, "password"))}, 'OLDDOM', 'password');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
rdp_user, rdp_password, rdp_auth_type)
VALUES (5, 'owner', 'ssh', 'copied', '10.0.0.5', 22, 'root', 'password',
'copied', ${quote(sealed("copied-pass", 5, "password"))}, 'none');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type)
VALUES (6, 'owner', 'ssh', 'plain-ssh', '10.0.0.6', 22, 'root', 'password');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
rdp_user)
VALUES (7, 'locked', 'ssh', 'later', '10.0.0.7', 22, 'root', 'password', 'waits');
`);
});
afterEach(async () => {
await database?.close();
database = null;
});
async function logins() {
const repository = createCurrentHostProtocolAuthRepository();
const out: Record<string, unknown> = {};
for (const hostId of [1, 2, 3, 4, 5, 6]) {
for (const login of await repository.listForHost(hostId, ownerKey)) {
out[`${hostId}:${login.protocol}`] = login;
}
}
return out;
}
const EXPECTED = {
"1:rdp": {
protocol: "rdp",
authType: "direct",
credentialId: null,
username: "admin",
password: "rdp-pass",
fields: { domain: "CORP" },
secretFields: {},
},
"2:vnc": {
protocol: "vnc",
authType: "credential",
credentialId: 5,
username: null,
password: null,
fields: {},
secretFields: {},
},
"3:telnet": {
protocol: "telnet",
authType: "direct",
credentialId: null,
username: "tu",
password: "tel-plain",
fields: {},
secretFields: {},
},
"4:rdp": {
protocol: "rdp",
authType: "direct",
credentialId: null,
username: "old-user",
password: "old-pass",
fields: { domain: "OLDDOM" },
secretFields: {},
},
"5:rdp": {
protocol: "rdp",
authType: "none",
credentialId: null,
username: null,
password: null,
fields: {},
secretFields: {},
},
};
describe("runProtocolAuthMigration", () => {
it("copies every 2.8 login shape, and a second run changes nothing", async () => {
const first = await runProtocolAuthMigration();
expect(first).toEqual({ copied: 5, pendingUsers: 1 });
expect(await logins()).toEqual(EXPECTED);
const second = await runProtocolAuthMigration();
expect(second).toEqual({ copied: 0, pendingUsers: 1 });
expect(await logins()).toEqual(EXPECTED);
});
it("leaves the legacy columns in place", async () => {
await runProtocolAuthMigration();
const [row] = await database!.query<{ rdp_user: string }>(
sql`SELECT rdp_user FROM ssh_data WHERE id = 1`,
);
expect(row.rdp_user).toBe("admin");
});
it("does not bring back a login the user removed", async () => {
await runProtocolAuthMigration();
await createCurrentHostProtocolAuthRepository().delete(1, "rdp");
await runProtocolAuthMigration();
expect(await logins()).not.toHaveProperty("1:rdp");
});
it("waits for a locked data key, then copies at that user's login", async () => {
await runProtocolAuthMigration();
const repository = createCurrentHostProtocolAuthRepository();
expect(await repository.listRowsForHost(7)).toEqual([]);
const lockedKey = crypto.randomBytes(32);
state.keys.set("locked", lockedKey);
expect(await runProtocolAuthMigration("locked")).toEqual({
copied: 1,
pendingUsers: 0,
});
expect(await repository.find(7, "rdp", lockedKey)).toMatchObject({
username: "waits",
});
const [marker] = await database!.query<{ value: string }>(
sql`SELECT value FROM settings WHERE key = ${copiedMarker("locked")}`,
);
expect(marker.value).toBe("done");
});
it("skips a host that already has a login, as after a partial run", async () => {
await createCurrentHostProtocolAuthRepository().upsert(
"owner",
1,
{
protocol: "rdp",
authType: "direct",
credentialId: null,
username: "edited",
password: null,
fields: {},
secretFields: {},
},
ownerKey,
);
await runProtocolAuthMigration();
expect((await logins())["1:rdp"]).toMatchObject({ username: "edited" });
expect(await logins()).toHaveProperty("3:telnet");
});
});
describe("legacyLogin rdp domain", () => {
const rdp = {
id: "rdp",
user: "rdp_user",
password: "rdp_password",
passwordField: "rdpPassword",
credential: "rdp_credential_id",
authType: "rdp_auth_type",
domain: "rdp_domain",
};
it.each([
{ saved: "CORP", legacy: null, expected: { domain: "CORP" } },
{ saved: "CORP", legacy: "OLD", expected: { domain: "CORP" } },
{ saved: "", legacy: "OLD", expected: {} },
{ saved: null, legacy: "LEGACY", expected: { domain: "LEGACY" } },
])(
"keeps saved domain $saved over legacy $legacy for a credential host",
({ saved, legacy, expected }) => {
const login = legacyLogin(
{
id: 1,
user_id: "owner",
connection_type: "rdp",
rdp_auth_type: "credential",
rdp_credential_id: 5,
rdp_user: null,
rdp_password: null,
rdp_domain: saved,
domain: legacy,
},
rdp,
ownerKey,
() => true,
);
expect(login?.fields).toEqual(expected);
},
);
});
@@ -0,0 +1,171 @@
/**
* The Proxmox host-columns migration.
*
* An upgrade must be lossless: a host that already had Proxmox discovery or
* stats configured must keep that configuration once the ssh_data columns
* are dropped. Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_proxmox: boolean;
proxmox_config: string | null;
enable_proxmox_stats: boolean;
proxmox_stats_config: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_proxmox ||
row.proxmox_config !== null ||
row.enable_proxmox_stats ||
row.proxmox_stats_config !== null,
),
}),
}));
const { runProxmoxSettingsMigration } =
await import("../../upgrade/proxmox-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runProxmoxSettingsMigration", () => {
it("moves an enabled host's columns into plugin settings", async () => {
hostRows.push({
id: 1,
enable_proxmox: true,
proxmox_config: JSON.stringify({ defaultAuthType: "password" }),
enable_proxmox_stats: true,
proxmox_stats_config: JSON.stringify({ nodeName: "pve1" }),
});
const result = await runProxmoxSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableProxmox")).toBe(true);
expect(storedValue(1, "proxmoxConfig")).toEqual({
defaultAuthType: "password",
});
expect(storedValue(1, "enableProxmoxStats")).toBe(true);
expect(storedValue(1, "proxmoxStatsConfig")).toEqual({ nodeName: "pve1" });
});
it("does nothing on a fresh install with no proxmox hosts", async () => {
const result = await runProxmoxSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({
id: 2,
enable_proxmox: false,
proxmox_config: JSON.stringify({ autoSyncEnabled: true }),
enable_proxmox_stats: false,
proxmox_stats_config: null,
});
await runProxmoxSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runProxmoxSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("moves each host independently", async () => {
hostRows.push(
{
id: 1,
enable_proxmox: true,
proxmox_config: null,
enable_proxmox_stats: false,
proxmox_stats_config: null,
},
{
id: 2,
enable_proxmox: false,
proxmox_config: null,
enable_proxmox_stats: true,
proxmox_stats_config: JSON.stringify({ nodeName: "pve2" }),
},
);
const result = await runProxmoxSettingsMigration();
expect(result.moved).toBe(2);
expect(storedValue(1, "enableProxmox")).toBe(true);
expect(storedValue(2, "proxmoxStatsConfig")).toEqual({ nodeName: "pve2" });
});
});
@@ -0,0 +1,256 @@
/**
* The Remote Desktop settings migration.
*
* An upgrade must be lossless: the admin switch and guacd address, each
* user's RDP defaults and every host's RDP/VNC/Telnet options must come
* through into the plugin's own settings, whatever shape an old host had.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const state = vi.hoisted(() => ({
coreSettings: new Map<string, string>(),
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
userRows: [] as Array<{ user_id: string; rdp_defaults: string | null }>,
statements: [] as string[],
pluginInstalled: true,
}));
const find = (scope: string, scopeId: string | null, key: string) =>
state.pluginRows.find(
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "remote-desktop" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks?: unknown[] }) => {
const text = JSON.stringify(query.queryChunks ?? query);
return text.includes("user_preferences") ? state.userRows : state.hostRows;
},
runStatement: async (query: unknown) => {
state.statements.push(JSON.stringify(query));
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const { runRemoteDesktopSettingsMigration, hostSettingsFromRow } =
await import("../../upgrade/remote-desktop-settings-migration.js");
const value = (scope: string, scopeId: string | null, key: string) => {
const row = find(scope, scopeId, key) as Row | undefined;
return row?.value == null ? undefined : JSON.parse(row.value);
};
function host(overrides: Record<string, unknown>) {
return {
id: 1,
port: 22,
connection_type: "ssh",
enable_rdp: 0,
enable_vnc: 0,
enable_telnet: 0,
rdp_port: 3389,
vnc_port: 5900,
telnet_port: 23,
rdp_security: null,
rdp_ignore_cert: 0,
security: null,
ignore_cert: 0,
guacamole_config: null,
enable_terminal_toolbar: 1,
...overrides,
};
}
beforeEach(() => {
state.coreSettings.clear();
state.pluginRows.length = 0;
state.hostRows.length = 0;
state.userRows.length = 0;
state.statements.length = 0;
state.pluginInstalled = true;
});
describe("runRemoteDesktopSettingsMigration", () => {
it("moves the admin switch and guacd address", async () => {
state.coreSettings.set("guac_enabled", "false");
state.coreSettings.set("guac_url", "guacd:4822");
const result = await runRemoteDesktopSettingsMigration();
expect(result.moved).toEqual(["guac_enabled", "guac_url"]);
expect(value("admin", null, "enabled")).toBe(false);
expect(value("admin", null, "guacdUrl")).toBe("guacd:4822");
});
it("moves a host's options, reading SQLite's 0 and 1 as booleans", async () => {
state.hostRows.push(
host({
id: 7,
enable_rdp: 1,
enable_vnc: 0,
rdp_port: 3390,
rdp_security: "nla",
rdp_ignore_cert: 1,
guacamole_config: JSON.stringify({ "color-depth": 24 }),
enable_terminal_toolbar: 0,
}),
);
const result = await runRemoteDesktopSettingsMigration();
expect(result.hostsMoved).toBe(1);
expect(value("host", "7", "enableRdp")).toBe(true);
expect(value("host", "7", "enableVnc")).toBeUndefined();
expect(value("host", "7", "rdpPort")).toBe(3390);
expect(value("host", "7", "rdpSecurity")).toBe("nla");
expect(value("host", "7", "rdpIgnoreCert")).toBe(true);
expect(value("host", "7", "guacamoleConfig")).toEqual({
"color-depth": 24,
});
expect(value("host", "7", "enableToolbar")).toBe(false);
});
it("leaves a plain SSH host alone", async () => {
state.hostRows.push(host({ id: 3 }));
const result = await runRemoteDesktopSettingsMigration();
expect(result.hostsMoved).toBe(0);
expect(state.pluginRows).toHaveLength(0);
});
it("reads real booleans too, as Postgres returns them", async () => {
state.hostRows.push(host({ id: 8, enable_vnc: true, vnc_port: 5901 }));
await runRemoteDesktopSettingsMigration();
expect(value("host", "8", "enableVnc")).toBe(true);
expect(value("host", "8", "vncPort")).toBe(5901);
});
it("moves each user's RDP defaults", async () => {
state.userRows.push({
user_id: "u1",
rdp_defaults: JSON.stringify({
colorDepth: 24,
resizeMethod: "reconnect",
disableCopy: true,
enableWallpaper: false,
}),
});
state.userRows.push({ user_id: "u2", rdp_defaults: null });
const result = await runRemoteDesktopSettingsMigration();
expect(result.usersMoved).toBe(1);
expect(value("user", "u1", "colorDepth")).toBe("24");
expect(value("user", "u1", "resizeMethod")).toBe("reconnect");
expect(value("user", "u1", "disableCopy")).toBe("on");
expect(value("user", "u1", "enableWallpaper")).toBe("off");
});
it("changes nothing when run twice", async () => {
state.coreSettings.set("guac_url", "guacd:4822");
state.hostRows.push(host({ id: 7, enable_rdp: 1, rdp_port: 3390 }));
state.userRows.push({
user_id: "u1",
rdp_defaults: JSON.stringify({ colorDepth: 16 }),
});
await runRemoteDesktopSettingsMigration();
const firstRun = JSON.stringify(state.pluginRows);
// Changed after the first run: must not be overwritten by the old values.
find("host", "7", "rdpPort")!.value = JSON.stringify(4000);
const second = await runRemoteDesktopSettingsMigration();
expect(second.moved).toEqual([]);
expect(second.usersMoved).toBe(0);
expect(second.hostsMoved).toBe(0);
expect(second.hostsSkipped).toBe(1);
expect(value("host", "7", "rdpPort")).toBe(4000);
expect(JSON.parse(firstRun)).toHaveLength(state.pluginRows.length);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
state.coreSettings.set("guac_url", "guacd:4822");
state.hostRows.push(host({ id: 7, enable_rdp: 1 }));
await runRemoteDesktopSettingsMigration();
expect(state.pluginRows).toHaveLength(0);
});
});
describe("hostSettingsFromRow", () => {
it("turns a connection_type-only host into its protocol switch and port", () => {
expect(
hostSettingsFromRow(
host({ connection_type: "vnc", port: 5905, vnc_port: null }),
),
).toEqual({ enableVnc: true, vncPort: 5905 });
});
it("folds the older security and ignore_cert columns in", () => {
expect(
hostSettingsFromRow(
host({
enable_rdp: 1,
rdp_security: null,
security: "tls",
rdp_ignore_cert: null,
ignore_cert: 1,
}),
),
).toEqual({ enableRdp: true, rdpSecurity: "tls", rdpIgnoreCert: true });
});
});
@@ -0,0 +1,206 @@
/**
* The secret source token migration.
*
* An upgrade must be lossless: every source's access token has to reach the
* secret-sources plugin's ctx.secrets, encrypted, and be gone from the
* table. Running it twice must not duplicate or clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const state = vi.hoisted(() => ({
pluginRows: [] as Row[],
sources: new Map<string, Array<Record<string, unknown>>>(),
pluginInstalled: true,
deks: new Set<string>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "secret-sources" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows.push({
pluginId,
scope,
scopeId,
key,
value,
encrypted,
});
},
}),
}));
/** The SQL text of a drizzle sql`` query, identifiers included. */
function text(query: { queryChunks: unknown[] }): string {
return query.queryChunks
.map((chunk) => {
if (typeof chunk === "string") return chunk;
const value = (chunk as { value?: unknown; name?: unknown }) ?? {};
if (Array.isArray(value.value)) return value.value.join("");
if (typeof value.value === "string") return value.value;
if (typeof value.name === "string") return value.name;
return String(chunk);
})
.join("");
}
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.sources) {
if (new RegExp(`FROM ${table} `).test(sqlText)) {
return rows.filter((row) => row.token);
}
}
throw new Error(`no such table in: ${sqlText}`);
},
runStatement: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.sources) {
if (!sqlText.includes(`UPDATE ${table} `)) continue;
const id = query.queryChunks.find((chunk) => typeof chunk === "string");
for (const row of rows) if (row.id === id) row.token = null;
}
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) =>
state.deks.has(userId) ? Buffer.from(userId) : null,
},
}));
vi.mock("../../utils/lazy-field-encryption.js", () => ({
LazyFieldEncryption: {
// Stands in for FieldCrypto: "enc:<plain>" decrypts, anything else is a
// legacy plaintext value.
safeGetFieldValue: (value: string) => value.replace(/^enc:/, ""),
},
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runSecretSourcesTokenMigration } from "../../upgrade/secret-sources-token-migration.js";
const value = (scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
(entry) =>
entry.scope === "secret" &&
entry.scopeId === scopeId &&
entry.key === key,
);
return row ? JSON.parse(row.value ?? "null") : undefined;
};
beforeEach(() => {
state.pluginRows = [];
state.sources = new Map([
[
"secret_sources",
[
{ id: "src-1", user_id: "u1", token: "enc:tok-one" },
{ id: "src-2", user_id: "u2", token: "tok-plain" },
],
],
]);
state.pluginInstalled = true;
state.deks = new Set(["u1", "u2"]);
});
describe("runSecretSourcesTokenMigration", () => {
it("moves each source's token into its owner's secrets, encrypted, and clears the column", async () => {
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 2 });
const secret = state.pluginRows.find(
(row) => row.scope === "secret" && row.key === "source:src-1",
);
expect(secret).toMatchObject({
scopeId: "u1",
encrypted: true,
value: JSON.stringify("sysenc:tok-one"),
});
expect(value("u2", "source:src-2")).toBe("sysenc:tok-plain");
expect(
state.sources.get("secret_sources")!.every((row) => row.token === null),
).toBe(true);
});
it("leaves a token alone while its owner's data key is unavailable", async () => {
state.deks = new Set(["u1"]);
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 1 });
expect(value("u2", "source:src-2")).toBeUndefined();
const row = state.sources
.get("secret_sources")!
.find((r) => r.id === "src-2");
expect(row?.token).toBe("tok-plain");
});
it("reads the adopted table once the plugin has renamed it", async () => {
state.sources = new Map([
[
"p_secret_sources_sources",
[{ id: "src-9", user_id: "u1", token: "enc:tok-late" }],
],
]);
await runSecretSourcesTokenMigration();
expect(value("u1", "source:src-9")).toBe("sysenc:tok-late");
});
it("changes nothing on a second run", async () => {
await runSecretSourcesTokenMigration();
const first = JSON.stringify(state.pluginRows);
const second = await runSecretSourcesTokenMigration();
expect(second).toEqual({ moved: 0 });
expect(JSON.stringify(state.pluginRows)).toBe(first);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 0 });
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,157 @@
/**
* The 2.8 session recording move. The retention period survives, so the
* plugin's first sweep does not prune with its own default, and each 2.8
* recording file moves into the plugin's folder with its row following it.
* Running it twice changes nothing.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
db: null as unknown,
core: new Map<string, string>(),
plugin: new Map<string, string>(),
installed: true,
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (h.installed ? { id } : null),
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => h.core.get(key) ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (_p: string, _s: string, _id: string | null, key: string) =>
h.plugin.has(key) ? { value: h.plugin.get(key) } : null,
set: async (
_p: string,
_s: string,
_id: string | null,
key: string,
value: string,
) => {
h.plugin.set(key, value);
},
}),
}));
const { runSessionRecordingDataMigration } =
await import("../../upgrade/session-recording-data-migration.js");
let dataDir: string;
let sqlite: Database.Database;
function write(relative: string, content: string): string {
const file = path.join(dataDir, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
return file;
}
function paths(): Record<number, string> {
return Object.fromEntries(
(
sqlite
.prepare(
"SELECT id, recording_path FROM p_session_recording_session_recordings",
)
.all() as Array<{ id: number; recording_path: string }>
).map((row) => [row.id, row.recording_path]),
);
}
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "recording-move-"));
vi.stubEnv("DATA_DIR", dataDir);
vi.stubEnv("SESSION_RECORDING_RETENTION_DAYS", "");
h.core.clear();
h.plugin.clear();
h.installed = true;
sqlite = new Database(":memory:");
sqlite.exec(`CREATE TABLE p_session_recording_session_recordings (
id INTEGER PRIMARY KEY, recording_path TEXT
)`);
h.db = drizzle(sqlite);
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(dataDir, { recursive: true, force: true });
});
describe("runSessionRecordingDataMigration", () => {
it("moves each 2.8 recording into the plugin's folder and points its row there", async () => {
const ssh = write("session_logs/user-1/s1.cast", "ssh");
const rdp = write("session_recordings/guacamole/r1.guac", "rdp");
const insert = sqlite.prepare(
"INSERT INTO p_session_recording_session_recordings (id, recording_path) VALUES (?, ?)",
);
insert.run(1, ssh);
insert.run(2, rdp);
insert.run(3, path.join(dataDir, "session_logs/user-1/gone.cast"));
insert.run(4, "/somewhere/else.cast");
const result = await runSessionRecordingDataMigration();
const base = path.join(dataDir, "plugin-data", "session-recording");
const moved = paths();
expect(moved[1]).toBe(path.join(base, "session_logs/user-1/s1.cast"));
expect(moved[2]).toBe(
path.join(base, "session_recordings/guacamole/r1.guac"),
);
expect(fs.readFileSync(moved[1], "utf8")).toBe("ssh");
expect(fs.readFileSync(moved[2], "utf8")).toBe("rdp");
expect(fs.existsSync(ssh)).toBe(false);
// A file 2.8 had already lost, and a path outside 2.8's folders, stay put.
expect(moved[3]).toBe(path.join(dataDir, "session_logs/user-1/gone.cast"));
expect(moved[4]).toBe("/somewhere/else.cast");
expect(result.moved).toBe(2);
const again = await runSessionRecordingDataMigration();
expect(again.moved).toBe(0);
expect(paths()).toEqual(moved);
});
it("keeps the 2.8 retention period", async () => {
h.core.set("session_recording_retention_days", "365");
await runSessionRecordingDataMigration();
expect(h.plugin.get("retentionDays")).toBe("365");
h.core.set("session_recording_retention_days", "90");
await runSessionRecordingDataMigration();
expect(h.plugin.get("retentionDays")).toBe("365");
});
it("falls back to the environment variable 2.8 read", async () => {
vi.stubEnv("SESSION_RECORDING_RETENTION_DAYS", "120");
await runSessionRecordingDataMigration();
expect(h.plugin.get("retentionDays")).toBe("120");
});
it("does nothing until the plugin is installed", async () => {
h.installed = false;
h.core.set("session_recording_retention_days", "365");
const ssh = write("session_logs/user-1/s1.cast", "ssh");
sqlite
.prepare(
"INSERT INTO p_session_recording_session_recordings (id, recording_path) VALUES (1, ?)",
)
.run(ssh);
await runSessionRecordingDataMigration();
expect(h.plugin.size).toBe(0);
expect(paths()[1]).toBe(ssh);
});
});
@@ -0,0 +1,169 @@
/**
* The session logging host-column migration.
*
* The legacy column defaulted to true, the plugin's enable switch defaults
* to false, so every host needs an explicit row, not just the ones that
* turned it off. Running it twice must not duplicate or clobber what it
* moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_session_logging: boolean | number | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => hostRows,
}),
}));
const { runSessionRecordingSettingsMigration } =
await import("../../upgrade/session-recording-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runSessionRecordingSettingsMigration", () => {
it("moves an enabled host's switch into plugin settings", async () => {
hostRows.push({ id: 1, enable_session_logging: true });
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableSessionRecording")).toBe(true);
expect(
settingsRows.every((row) => row.pluginId === "session-recording"),
).toBe(true);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("moves a disabled host's switch too, since the defaults flipped", async () => {
hostRows.push({ id: 3, enable_session_logging: false });
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(3, "enableSessionRecording")).toBe(false);
});
it("reads SQLite 0 and 1 as off and on", async () => {
hostRows.push({ id: 6, enable_session_logging: 0 });
hostRows.push({ id: 7, enable_session_logging: 1 });
await runSessionRecordingSettingsMigration();
expect(storedValue(6, "enableSessionRecording")).toBe(false);
expect(storedValue(7, "enableSessionRecording")).toBe(true);
});
it("treats a null legacy value as on, matching the old column default", async () => {
hostRows.push({ id: 4, enable_session_logging: null });
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "enableSessionRecording")).toBe(true);
});
it("does nothing on a fresh install", async () => {
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_session_logging: true });
await runSessionRecordingSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runSessionRecordingSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({ id: 5, enable_session_logging: true });
await pluginSettingsRepository.set(
"session-recording",
"host",
"5",
"enableSessionRecording",
"false",
);
const result = await runSessionRecordingSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableSessionRecording")).toBe(false);
});
});
@@ -0,0 +1,151 @@
/**
* The session sharing settings migration.
*
* An upgrade must be lossless: an instance with sharing switched off, and a
* host with sharing switched off, must stay off once the plugin reads its own
* settings. Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const blockedHostIds: number[] = [];
const coreSettings = new Map<string, string>();
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => blockedHostIds.map((id) => ({ id })),
}),
}));
const { runSessionSharingSettingsMigration } =
await import("../../upgrade/session-sharing-settings-migration.js");
function stored(scopeId: string | null, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === scopeId && entry.key === key,
);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
blockedHostIds.length = 0;
coreSettings.clear();
settingsRows.length = 0;
});
describe("runSessionSharingSettingsMigration", () => {
it("moves a disabled global switch into the admin setting", async () => {
coreSettings.set("session_sharing_globally_enabled", "false");
const result = await runSessionSharingSettingsMigration();
expect(result.movedGlobal).toBe(true);
expect(stored(null, "globallyEnabled")).toBe(false);
expect(settingsRows[0]).toMatchObject({
pluginId: "session-sharing",
scope: "admin",
});
});
it("moves hosts that turned sharing off into host settings", async () => {
blockedHostIds.push(4, 9);
const result = await runSessionSharingSettingsMigration();
expect(result.movedHosts).toBe(2);
expect(stored("4", "allowSessionSharing")).toBe(false);
expect(stored("9", "allowSessionSharing")).toBe(false);
});
it("writes nothing when sharing was on everywhere", async () => {
coreSettings.set("session_sharing_globally_enabled", "true");
const result = await runSessionSharingSettingsMigration();
expect(result).toEqual({ movedGlobal: false, movedHosts: 0, skipped: 0 });
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
coreSettings.set("session_sharing_globally_enabled", "false");
blockedHostIds.push(2);
await runSessionSharingSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runSessionSharingSettingsMigration();
expect(second.movedGlobal).toBe(false);
expect(second.movedHosts).toBe(0);
expect(second.skipped).toBe(2);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
blockedHostIds.push(5);
await pluginSettingsRepository.set(
"session-sharing",
"host",
"5",
"allowSessionSharing",
"true",
);
const result = await runSessionSharingSettingsMigration();
expect(result.skipped).toBe(1);
expect(stored("5", "allowSessionSharing")).toBe(true);
});
});
@@ -0,0 +1,174 @@
/**
* The snippets settings migration: host quick actions, the startup snippet
* and the two snippet user preferences must come through into the snippets
* plugin's settings, and running it twice must change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const pluginRows: Row[] = [];
const hostRows: Array<Record<string, unknown>> = [];
const preferenceRows: Array<Record<string, unknown>> = [];
let pluginInstalled = true;
const find = (scope: string, scopeId: string | null, key: string) =>
pluginRows.find(
(row) =>
row.pluginId === "snippets" &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "snippets" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(scope, scopeId, key);
if (existing) existing.value = value;
else pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async (query: unknown) =>
JSON.stringify(query).includes("user_preferences")
? preferenceRows
: hostRows,
}),
}));
const { runSnippetsSettingsMigration, snippetHostSettingsFromRow } =
await import("../../upgrade/snippets-settings-migration.js");
function value(scope: string, scopeId: string, key: string): unknown {
const row = find(scope, scopeId, key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
pluginRows.length = 0;
hostRows.length = 0;
preferenceRows.length = 0;
pluginInstalled = true;
});
describe("snippetHostSettingsFromRow", () => {
it("reads quick actions and the startup snippet, dropping broken rows", () => {
expect(
snippetHostSettingsFromRow({
quick_actions: JSON.stringify([
{ name: "Restart", snippetId: 3 },
{ name: "Broken", snippetId: "x" },
{ name: "Str", snippetId: "4" },
]),
terminal_config: JSON.stringify({ fontSize: 14, startupSnippetId: 9 }),
}),
).toEqual({
quickActions: [
{ name: "Restart", snippetId: 3 },
{ name: "Str", snippetId: 4 },
],
startupSnippetId: 9,
});
});
it("reads nothing from a row without either", () => {
expect(
snippetHostSettingsFromRow({
quick_actions: "[]",
terminal_config: JSON.stringify({ fontSize: 14 }),
}),
).toEqual({});
});
});
describe("runSnippetsSettingsMigration", () => {
it("moves host and user values once", async () => {
hostRows.push(
{
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
},
{ id: 2, quick_actions: null, terminal_config: "{}" },
);
preferenceRows.push(
{ user_id: "u1", confirm_snippet_execution: 1, folders_collapsed: 0 },
{
user_id: "u2",
confirm_snippet_execution: null,
folders_collapsed: null,
},
);
const first = await runSnippetsSettingsMigration();
expect(first).toEqual({ hostsMoved: 1, hostsSkipped: 0, usersMoved: 1 });
expect(value("host", "1", "quickActions")).toEqual([
{ name: "Up", snippetId: 2 },
]);
expect(value("host", "1", "startupSnippetId")).toBe(5);
expect(value("user", "u1", "confirmExecution")).toBe(true);
expect(value("user", "u1", "foldersCollapsed")).toBe(false);
expect(find("user", "u2", "confirmExecution")).toBeUndefined();
const snapshot = JSON.stringify(pluginRows);
const second = await runSnippetsSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1, usersMoved: 0 });
expect(JSON.stringify(pluginRows)).toBe(snapshot);
});
it("never overwrites what the user already changed in the plugin", async () => {
pluginRows.push({
pluginId: "snippets",
scope: "host",
scopeId: "1",
key: "startupSnippetId",
value: "7",
});
hostRows.push({
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
});
await runSnippetsSettingsMigration();
expect(value("host", "1", "startupSnippetId")).toBe(7);
expect(find("host", "1", "quickActions")).toBeUndefined();
});
it("does nothing while the plugin is not installed", async () => {
pluginInstalled = false;
hostRows.push({
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: null,
});
await runSnippetsSettingsMigration();
expect(pluginRows).toHaveLength(0);
});
});
@@ -0,0 +1,248 @@
/**
* The terminal's look and behavior moving out of ssh_data.terminal_config and
* user_preferences into the ssh-terminal plugin's settings. Lossless, and a
* second run changes nothing.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const state = vi.hoisted(() => ({
db: null as unknown,
rows: [] as Row[],
installed: true,
}));
const find = (scope: string, scopeId: string | null, key: string) =>
state.rows.find(
(row) =>
row.pluginId === "ssh-terminal" &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.installed && id === "ssh-terminal" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
getAllForScopeIds: async (scope: string, scopeIds: string[]) =>
state.rows.filter(
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(scope, scopeId, key);
if (existing) existing.value = value;
else state.rows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
const {
runSshTerminalLookMigration,
hostSettingsFromTerminalConfig,
userSettingsFromPreferences,
} = await import("../../upgrade/ssh-terminal-look-migration.js");
let sqlite: Database.Database;
beforeEach(() => {
state.rows.length = 0;
state.installed = true;
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, terminal_config TEXT);
CREATE TABLE user_preferences (
user_id TEXT PRIMARY KEY,
terminal_defaults TEXT,
custom_themes TEXT,
command_autocomplete INTEGER
);
`);
state.db = drizzle(sqlite);
});
function host(id: number, key: string): unknown {
const row = find("host", String(id), key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
function user(id: string, key: string): unknown {
const row = find("user", id, key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
describe("hostSettingsFromTerminalConfig", () => {
it("keeps a host that followed the user's look following it", () => {
expect(
hostSettingsFromTerminalConfig(
JSON.stringify({
autoTmux: true,
localEcho: "on",
keepaliveInterval: 30,
startupSnippetId: 3,
}),
),
).toEqual({ autoTmux: true, localEcho: "on" });
});
it("opts a host with its own look out of the user's defaults", () => {
expect(
hostSettingsFromTerminalConfig({
theme: "Termix Dark",
fontSize: "16",
cursorStyle: "wobble",
bellStyle: "visual",
}),
).toEqual({
inheritAppearance: false,
theme: "termix",
fontSize: 16,
bellStyle: "visual",
});
});
it("reads nothing out of garbage", () => {
expect(hostSettingsFromTerminalConfig("not json")).toEqual({});
expect(hostSettingsFromTerminalConfig(null)).toEqual({});
});
});
describe("userSettingsFromPreferences", () => {
it("takes the defaults, saved themes and autocomplete", () => {
expect(
userSettingsFromPreferences({
terminal_defaults: JSON.stringify({ fontSize: 18, theme: "nord" }),
custom_themes: JSON.stringify([
{ id: "a", name: "Mine", colors: { background: "#000" } },
]),
command_autocomplete: 1,
}),
).toEqual({
terminalDefaults: { fontSize: 18, theme: "nord" },
customThemes: [{ id: "a", name: "Mine", colors: { background: "#000" } }],
commandAutocomplete: true,
});
expect(userSettingsFromPreferences({ command_autocomplete: null })).toEqual(
{},
);
});
});
describe("runSshTerminalLookMigration", () => {
function seed() {
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(
1,
JSON.stringify({
theme: "dracula",
fontSize: 18,
autoTmux: true,
sudoPasswordAutoFill: true,
keepaliveInterval: 30,
}),
);
sqlite
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
.run(2, JSON.stringify({ passwordPromptAutoFill: false }));
sqlite
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
.run(3, JSON.stringify({ keepaliveInterval: 10 }));
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(4, null);
sqlite
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
.run(
"u1",
JSON.stringify({ cursorBlink: false }),
JSON.stringify([
{ id: "t", name: "T", colors: { background: "#111" } },
]),
1,
);
sqlite
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
.run("u2", null, null, null);
}
it("copies each host's terminal keys and each user's terminal preferences", async () => {
seed();
const result = await runSshTerminalLookMigration();
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0, usersMoved: 1 });
expect(host(1, "inheritAppearance")).toBe(false);
expect(host(1, "theme")).toBe("dracula");
expect(host(1, "fontSize")).toBe(18);
expect(host(1, "autoTmux")).toBe(true);
expect(host(1, "sudoPasswordAutoFill")).toBe(true);
expect(host(1, "keepaliveInterval")).toBeUndefined();
expect(host(2, "passwordPromptAutoFill")).toBe(false);
expect(host(2, "inheritAppearance")).toBeUndefined();
expect(host(3, "keepaliveInterval")).toBeUndefined();
expect(user("u1", "terminalDefaults")).toEqual({ cursorBlink: false });
expect(user("u1", "customThemes")).toHaveLength(1);
expect(user("u1", "commandAutocomplete")).toBe(true);
expect(state.rows.some((row) => row.scopeId === "u2")).toBe(false);
});
it("changes nothing on a second run, even after values were edited", async () => {
seed();
await runSshTerminalLookMigration();
find("host", "1", "fontSize")!.value = JSON.stringify(12);
find("user", "u1", "commandAutocomplete")!.value = JSON.stringify(false);
const before = JSON.stringify(state.rows);
const second = await runSshTerminalLookMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 2, usersMoved: 0 });
expect(JSON.stringify(state.rows)).toBe(before);
expect(host(1, "fontSize")).toBe(12);
});
it("waits for the plugin row", async () => {
seed();
state.installed = false;
expect(await runSshTerminalLookMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
usersMoved: 0,
});
expect(state.rows).toEqual([]);
});
it("copies nothing from a database without the columns", async () => {
sqlite = new Database(":memory:");
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
state.db = drizzle(sqlite);
expect(await runSshTerminalLookMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
usersMoved: 0,
});
});
});
@@ -0,0 +1,104 @@
/**
* The terminal macros migration: a user's saved macros must come through
* into the ssh-terminal plugin's macros setting, and running it twice must
* change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const pluginRows: Row[] = [];
const preferenceRows: Array<Record<string, unknown>> = [];
const find = (scopeId: string, key: string) =>
pluginRows.find(
(row) =>
row.pluginId === "ssh-terminal" &&
row.scope === "user" &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (id === "ssh-terminal" ? { id } : null),
}),
createCurrentPluginSettingsRepository: () => ({
get: async (_p: string, _s: string, scopeId: string, key: string) =>
find(scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
value: string | null,
) => {
const existing = find(scopeId, key);
if (existing) existing.value = value;
else pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({ all: async () => preferenceRows }),
}));
const { runSshTerminalMacrosMigration, macrosFromPreferences } =
await import("../../upgrade/ssh-terminal-macros-migration.js");
const macro = { id: "m1", name: "Deploy", steps: [{ id: "s", type: "send" }] };
beforeEach(() => {
pluginRows.length = 0;
preferenceRows.length = 0;
});
describe("macrosFromPreferences", () => {
it("keeps valid macros and drops the rest", () => {
expect(
macrosFromPreferences(JSON.stringify([macro, { id: 1 }, "x"])),
).toEqual([macro]);
expect(macrosFromPreferences("[]")).toBeNull();
expect(macrosFromPreferences("{bad")).toBeNull();
});
});
describe("runSshTerminalMacrosMigration", () => {
it("moves each user's macros once", async () => {
preferenceRows.push(
{ user_id: "u1", terminal_macros: JSON.stringify([macro]) },
{ user_id: "u2", terminal_macros: "[]" },
);
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 1 });
expect(JSON.parse(find("u1", "macros")!.value!)).toEqual([macro]);
expect(find("u2", "macros")).toBeUndefined();
const snapshot = JSON.stringify(pluginRows);
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 0 });
expect(JSON.stringify(pluginRows)).toBe(snapshot);
});
it("leaves a user who already has macros in the plugin alone", async () => {
pluginRows.push({
pluginId: "ssh-terminal",
scope: "user",
scopeId: "u1",
key: "macros",
value: "[]",
});
preferenceRows.push({
user_id: "u1",
terminal_macros: JSON.stringify([macro]),
});
await runSshTerminalMacrosMigration();
expect(find("u1", "macros")!.value).toBe("[]");
});
});
@@ -0,0 +1,241 @@
/**
* The SSH terminal settings migration.
*
* An upgrade must be lossless: an install's session timeout, command history
* switch, touch tuning and image storage settings, and every host that turned
* a terminal switch off, must come through into the plugin's own settings.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
interface HostRow {
id: number;
enable_terminal: number | null;
enable_terminal_toolbar: number | null;
enable_command_history: number | null;
}
const coreSettings = new Map<string, string>();
const pluginRows: Row[] = [];
const hostRows: HostRow[] = [];
let pluginInstalled = true;
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "ssh-terminal" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
existing.encrypted = encrypted;
return;
}
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_terminal === 0 ||
row.enable_terminal_toolbar === 0 ||
row.enable_command_history === 0,
),
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (plaintext: string) =>
plaintext.startsWith("sysenc:") ? plaintext : `sysenc:${plaintext}`,
}));
const { runSshTerminalSettingsMigration } =
await import("../../upgrade/ssh-terminal-settings-migration.js");
function admin(key: string): unknown {
const row = find("ssh-terminal", "admin", null, key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
function host(id: number, key: string): unknown {
const row = find("ssh-terminal", "host", String(id), key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
coreSettings.clear();
pluginRows.length = 0;
hostRows.length = 0;
pluginInstalled = true;
});
describe("runSshTerminalSettingsMigration", () => {
it("moves the admin settings with their types, the local dir as a secret", async () => {
coreSettings.set("terminal_session_timeout_minutes", "120");
coreSettings.set("terminal_session_persistence_enabled", "false");
coreSettings.set("command_history_enabled", "false");
coreSettings.set(
"touch_input_settings",
JSON.stringify({ enabled: false, dragThresholdPx: 12 }),
);
coreSettings.set("terminal_image_storage_mode", "local");
coreSettings.set("terminal_image_local_dir", "/data/images");
coreSettings.set("terminal_image_max_count", "50");
const result = await runSshTerminalSettingsMigration();
expect(result.moved).toHaveLength(7);
expect(admin("sessionTimeoutMinutes")).toBe(120);
expect(admin("sessionPersistence")).toBe(false);
expect(admin("commandHistoryEnabled")).toBe(false);
expect(admin("touchInput")).toEqual({
enabled: false,
dragThresholdPx: 12,
});
expect(admin("imageStorageMode")).toBe("local");
expect(admin("imageMaxCount")).toBe(50);
const localDir = find("ssh-terminal", "admin", null, "imageLocalDir");
expect(localDir?.encrypted).toBe(true);
expect(JSON.parse(localDir!.value!)).toBe("sysenc:/data/images");
});
it("moves the terminal half of the admin host defaults to the new-host settings", async () => {
coreSettings.set(
"host_defaults",
JSON.stringify({
useSocks5: true,
fontSize: 18,
fontFamily: "Fira Code",
theme: "Termix Dark",
cursorStyle: "wobble",
cursorBlink: false,
autoTmux: true,
}),
);
await runSshTerminalSettingsMigration();
expect(admin("newHostFontSize")).toBe(18);
expect(admin("newHostFontFamily")).toBe("Fira Code");
expect(admin("newHostTheme")).toBe("termix");
expect(admin("newHostCursorStyle")).toBeUndefined();
expect(admin("newHostCursorBlink")).toBe(false);
expect(admin("newHostAutoTmux")).toBe(true);
});
it("copies each host that switched something off, keeping the rest on", async () => {
hostRows.push(
{
id: 1,
enable_terminal: 1,
enable_terminal_toolbar: 0,
enable_command_history: 1,
},
{
id: 2,
enable_terminal: 0,
enable_terminal_toolbar: 1,
enable_command_history: 0,
},
{
id: 3,
enable_terminal: 1,
enable_terminal_toolbar: 1,
enable_command_history: 1,
},
);
const result = await runSshTerminalSettingsMigration();
expect(result.hostsMoved).toBe(2);
expect(host(1, "enableTerminal")).toBe(true);
expect(host(1, "enableTerminalToolbar")).toBe(false);
expect(host(1, "enableCommandHistory")).toBe(true);
expect(host(2, "enableTerminal")).toBe(false);
expect(host(2, "enableCommandHistory")).toBe(false);
// All on is the plugin's default, so it needs no row.
expect(host(3, "enableTerminal")).toBeUndefined();
});
it("changes nothing on a second run, even after the admin changed a value", async () => {
coreSettings.set("terminal_session_timeout_minutes", "120");
hostRows.push({
id: 1,
enable_terminal: 0,
enable_terminal_toolbar: 1,
enable_command_history: 1,
});
await runSshTerminalSettingsMigration();
const changed = find(
"ssh-terminal",
"admin",
null,
"sessionTimeoutMinutes",
);
changed!.value = JSON.stringify(15);
const rowsBefore = pluginRows.length;
const second = await runSshTerminalSettingsMigration();
expect(second.moved).toEqual([]);
expect(second.hostsMoved).toBe(0);
expect(second.hostsSkipped).toBe(1);
expect(pluginRows).toHaveLength(rowsBefore);
expect(admin("sessionTimeoutMinutes")).toBe(15);
});
it("does nothing until the plugin row exists", async () => {
pluginInstalled = false;
coreSettings.set("terminal_session_timeout_minutes", "120");
const result = await runSshTerminalSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
});
@@ -0,0 +1,79 @@
/**
* The silent sign-in default moving from core settings into the sso plugin's
* admin settings.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
core: new Map<string, string>(),
plugin: new Map<string, string | null>(),
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "sso" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.core.get(key) ?? null,
delete: async (key: string) => {
state.core.delete(key);
},
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => (state.plugin.has(key) ? { value: state.plugin.get(key) } : null),
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
) => {
state.plugin.set(key, value);
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runSsoSettingsMigration } from "../../upgrade/sso-settings-migration.js";
beforeEach(() => {
state.core = new Map();
state.plugin = new Map();
state.pluginInstalled = true;
});
describe("runSsoSettingsMigration", () => {
it("moves the core value into the sso admin setting", async () => {
state.core.set("oidc_silent_login_default", "true");
expect(await runSsoSettingsMigration()).toEqual({ moved: true });
expect(state.plugin.get("silentLoginDefault")).toBe("true");
expect(state.core.has("oidc_silent_login_default")).toBe(false);
});
it("keeps a value already in plugin settings", async () => {
state.core.set("oidc_silent_login_default", "true");
state.plugin.set("silentLoginDefault", "false");
await runSsoSettingsMigration();
expect(state.plugin.get("silentLoginDefault")).toBe("false");
expect(state.core.has("oidc_silent_login_default")).toBe(false);
});
it("does nothing twice or without the plugin", async () => {
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
state.core.set("oidc_silent_login_default", "true");
state.pluginInstalled = false;
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
expect(state.core.get("oidc_silent_login_default")).toBe("true");
});
});
@@ -0,0 +1,116 @@
/**
* The Step CA settings migration: a 2.8 CA configuration and its private
* endpoint allowlist come through into the step-ca plugin's admin settings,
* the old redirect URI stays in use, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
legacy: {} as Record<string, string>,
pluginRows: [] as Array<{ key: string; value: string | null }>,
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "step-ca" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.legacy[key] ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => state.pluginRows.find((row) => row.key === key) ?? null,
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
state.pluginRows.push({ key, value });
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runStepCaSettingsMigration } from "../../upgrade/step-ca-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.legacy = {
step_ca_url: "https://ca.internal:9000",
step_ca_fingerprint: "a".repeat(64),
step_ca_provisioner: "oidc",
step_ca_private_endpoint_allowlist: JSON.stringify([
"ca.internal",
"SSO.internal",
]),
};
state.pluginRows = [];
state.pluginInstalled = true;
});
describe("runStepCaSettingsMigration", () => {
it("moves the 2.8 settings and keeps the old redirect URI", async () => {
const result = await runStepCaSettingsMigration();
expect(result.moved).toEqual([
"step_ca_url",
"step_ca_fingerprint",
"step_ca_provisioner",
"step_ca_private_endpoint_allowlist",
]);
expect(result.legacyCallback).toBe(true);
expect(value("caUrl")).toBe("https://ca.internal:9000");
expect(value("fingerprint")).toBe("a".repeat(64));
expect(value("provisioner")).toBe("oidc");
expect(value("privateEndpoints")).toBe("ca.internal, sso.internal");
expect(value("legacyCallback")).toBe(true);
});
it("changes nothing the second time", async () => {
await runStepCaSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const again = await runStepCaSettingsMigration();
expect(again).toEqual({ moved: [], legacyCallback: false });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("never overwrites a value set in the plugin", async () => {
state.pluginRows = [
{ key: "caUrl", value: JSON.stringify("https://new.ca") },
{ key: "legacyCallback", value: JSON.stringify(false) },
];
await runStepCaSettingsMigration();
expect(value("caUrl")).toBe("https://new.ca");
expect(value("legacyCallback")).toBe(false);
expect(value("provisioner")).toBe("oidc");
});
it("leaves an install that never used Step CA alone", async () => {
state.legacy = {};
const result = await runStepCaSettingsMigration();
expect(result).toEqual({ moved: [], legacyCallback: false });
expect(state.pluginRows).toEqual([]);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
await runStepCaSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,228 @@
/**
* The Tailscale settings migration.
*
* An upgrade must be lossless: an install that had a working API key must keep
* working without anyone re-entering it. That is the whole point of the test,
* and running it twice proves a restart cannot corrupt what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const coreSettings = new Map<string, string>();
const pluginRows: Row[] = [];
let pluginInstalled = true;
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
const existing = pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
existing.encrypted = encrypted;
return;
}
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "tailscale" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
delete: async (key: string) => coreSettings.delete(key),
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
encryptSystemSecret: async (plaintext: string) =>
!plaintext || plaintext.startsWith("sysenc:v1:")
? plaintext
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
decryptSystemSecret: async (stored: string) =>
stored.startsWith("sysenc:v1:")
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
"utf8",
)
: stored,
}));
const { runTailscaleSettingsMigration } =
await import("../../upgrade/tailscale-settings-migration.js");
function storedValue(key: string): unknown {
const row = pluginRows.find((entry) => entry.key === key);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
coreSettings.clear();
pluginRows.length = 0;
pluginInstalled = true;
});
describe("runTailscaleSettingsMigration", () => {
it("moves an existing key and base URL into plugin settings", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([
"tailscale_api_key",
"tailscale_api_base_url",
]);
expect(storedValue("apiKey")).toMatch(/^sysenc:v1:/);
expect(storedValue("apiBaseUrl")).toBe("https://headscale.example");
});
it("encrypts the key, which was stored in plaintext before", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
await runTailscaleSettingsMigration();
const row = pluginRows.find((entry) => entry.key === "apiKey")!;
expect(row.encrypted).toBe(true);
expect(row.value).not.toContain("tskey-api-legacy");
});
it("leaves the base URL readable, since it is not a secret", async () => {
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
const row = pluginRows.find((entry) => entry.key === "apiBaseUrl")!;
expect(row.encrypted).toBe(false);
});
it("removes the legacy rows so no plaintext key is left behind", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
expect(coreSettings.has("tailscale_api_key")).toBe(false);
expect(coreSettings.has("tailscale_api_base_url")).toBe(false);
});
it("is idempotent: a second run changes nothing", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(pluginRows));
const second = await runTailscaleSettingsMigration();
expect(second.moved).toEqual([]);
expect(pluginRows).toEqual(afterFirst);
});
it("cannot double-encrypt the key across runs", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
await runTailscaleSettingsMigration();
const first = storedValue("apiKey");
await runTailscaleSettingsMigration();
expect(storedValue("apiKey")).toBe(first);
expect(String(storedValue("apiKey")).match(/sysenc:v1:/g)).toHaveLength(1);
});
it("drops a stale legacy row when the value was already moved", async () => {
// An interrupted run, or a downgrade that rewrote the old key.
await pluginSettingsRepository.set(
"tailscale",
"admin",
null,
"apiKey",
JSON.stringify("sysenc:v1:already"),
true,
);
coreSettings.set("tailscale_api_key", "tskey-plaintext-leftover");
const result = await runTailscaleSettingsMigration();
expect(result.skipped).toContain("tailscale_api_key");
expect(storedValue("apiKey")).toBe("sysenc:v1:already");
expect(coreSettings.has("tailscale_api_key")).toBe(false);
});
it("does nothing on a fresh install with no legacy rows", async () => {
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
it("skips an empty legacy value rather than storing one", async () => {
coreSettings.set("tailscale_api_key", "");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
it("waits for the plugin row, because of the foreign key", async () => {
pluginInstalled = false;
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
// Still there for the next boot, once the plugin is seeded.
expect(coreSettings.get("tailscale_api_key")).toBe("tskey-api-legacy");
});
it("moves each key independently", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual(["tailscale_api_key"]);
expect(storedValue("apiBaseUrl")).toBeUndefined();
});
});
@@ -0,0 +1,100 @@
/**
* The telemetry settings migration: an instance that turned telemetry off in
* 2.8 stays off, keeps its instance id, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface SettingsRow {
pluginId: string;
key: string;
value: string | null;
}
const coreSettings = new Map<string, string>();
const settingsRows: SettingsRow[] = [];
let pluginInstalled = true;
const find = (pluginId: string, key: string) =>
settingsRows.find((row) => row.pluginId === pluginId && row.key === key);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (pluginInstalled ? { id } : null),
}),
createCurrentPluginSettingsRepository: () => ({
get: async (pluginId: string, _scope: string, _id: null, key: string) =>
find(pluginId, key) ?? null,
set: async (
pluginId: string,
_scope: string,
_id: null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, key);
if (existing) existing.value = value;
else settingsRows.push({ pluginId, key, value });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
const { runTelemetrySettingsMigration } =
await import("../../upgrade/telemetry-settings-migration.js");
const stored = (key: string) => {
const row = find("telemetry", key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
describe("runTelemetrySettingsMigration", () => {
beforeEach(() => {
coreSettings.clear();
settingsRows.length = 0;
pluginInstalled = true;
});
it("moves a disabled switch and the instance id", async () => {
coreSettings.set("analytics_enabled", "false");
coreSettings.set("analytics_instance_id", "abc-123");
const result = await runTelemetrySettingsMigration();
expect(result).toEqual({ movedEnabled: true, movedInstanceId: true });
expect(stored("enabled")).toBe(false);
expect(stored("instanceId")).toBe("abc-123");
});
it("writes nothing for an enabled instance without an id", async () => {
coreSettings.set("analytics_enabled", "true");
await runTelemetrySettingsMigration();
expect(settingsRows).toHaveLength(0);
});
it("is idempotent and never overwrites plugin values", async () => {
coreSettings.set("analytics_enabled", "false");
coreSettings.set("analytics_instance_id", "old");
settingsRows.push({
pluginId: "telemetry",
key: "instanceId",
value: JSON.stringify("new"),
});
await runTelemetrySettingsMigration();
const second = await runTelemetrySettingsMigration();
expect(second).toEqual({ movedEnabled: false, movedInstanceId: false });
expect(stored("instanceId")).toBe("new");
expect(settingsRows).toHaveLength(2);
});
it("does nothing without the plugin row", async () => {
pluginInstalled = false;
coreSettings.set("analytics_enabled", "false");
await runTelemetrySettingsMigration();
expect(settingsRows).toHaveLength(0);
});
});
@@ -0,0 +1,114 @@
/**
* The 2.8 to 2.9 Termix ID CA move. A CA private key encrypted with the
* owner's data key must end up sealed with the installation key in the
* plugin's adopted table, readable, and a second run changes nothing.
*/
import crypto from "node:crypto";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FieldCrypto } from "../../utils/field-crypto.js";
const h = vi.hoisted(() => ({
db: null as unknown,
deks: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
encryptSystemSecret: async (value: string) => `sysenc:v1:${value}`,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runTermixIdentityCaMigration } =
await import("../../upgrade/termix-identity-ca-migration.js");
const PEM = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n";
let sqlite: Database.Database;
function setup({ pluginTable }: { pluginTable: boolean }) {
sqlite = new Database(":memory:");
if (pluginTable) {
sqlite.exec(`
CREATE TABLE p_termix_identity_ca (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL UNIQUE,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
validity_days INTEGER NOT NULL DEFAULT 90
);
`);
}
h.db = drizzle(sqlite);
}
function addCa(id: number, userId: string, withKey = true) {
const dek = crypto.randomBytes(32);
if (withKey) h.deks.set(userId, dek);
sqlite
.prepare(
"INSERT INTO p_termix_identity_ca (id, identity_id, user_id, public_key, private_key) VALUES (?, ?, ?, 'ssh-ed25519 AAAA', ?)",
)
.run(
id,
id,
userId,
FieldCrypto.encryptField(PEM, dek, String(id), "privateKey"),
);
}
const keyOf = (id: number) =>
(
sqlite
.prepare("SELECT private_key FROM p_termix_identity_ca WHERE id = ?")
.get(id) as { private_key: string }
).private_key;
beforeEach(() => {
h.deks.clear();
});
describe("runTermixIdentityCaMigration", () => {
it("does nothing before the plugin adopted the table", async () => {
setup({ pluginTable: false });
expect(await runTermixIdentityCaMigration()).toBe(0);
});
it("reseals a data-key CA with the installation key", async () => {
setup({ pluginTable: true });
addCa(1, "alice");
expect(await runTermixIdentityCaMigration()).toBe(1);
expect(keyOf(1)).toBe(`sysenc:v1:${PEM}`);
});
it("changes nothing on a second run", async () => {
setup({ pluginTable: true });
addCa(1, "alice");
await runTermixIdentityCaMigration();
const first = keyOf(1);
expect(await runTermixIdentityCaMigration()).toBe(0);
expect(keyOf(1)).toBe(first);
});
it("leaves a row whose owner's key is not open for the next run", async () => {
setup({ pluginTable: true });
addCa(1, "alice");
addCa(2, "bob", false);
const locked = keyOf(2);
expect(await runTermixIdentityCaMigration()).toBe(1);
expect(keyOf(2)).toBe(locked);
});
});
@@ -0,0 +1,149 @@
/**
* The tmux monitor host-column migration.
*
* An upgrade must be lossless: a host with the monitor on must keep it on
* once the ssh_data column is dropped. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_tmux_monitor: boolean;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => hostRows.filter((row) => row.enable_tmux_monitor),
}),
}));
const { runTmuxMonitorSettingsMigration } =
await import("../../upgrade/tmux-monitor-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runTmuxMonitorSettingsMigration", () => {
it("moves a host's enabled switch into plugin settings", async () => {
hostRows.push({ id: 1, enable_tmux_monitor: true });
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableTmuxMonitor")).toBe(true);
expect(settingsRows.every((row) => row.pluginId === "tmux-monitor")).toBe(
true,
);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("leaves a disabled host untouched", async () => {
hostRows.push({ id: 3, enable_tmux_monitor: false });
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(0);
expect(storedValue(3, "enableTmuxMonitor")).toBeUndefined();
});
it("does nothing on a fresh install", async () => {
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_tmux_monitor: true });
await runTmuxMonitorSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runTmuxMonitorSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({ id: 5, enable_tmux_monitor: true });
await pluginSettingsRepository.set(
"tmux-monitor",
"host",
"5",
"enableTmuxMonitor",
"false",
);
const result = await runTmuxMonitorSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableTmuxMonitor")).toBe(false);
});
});
@@ -0,0 +1,207 @@
/**
* The 2.8 to 2.9 TOTP move. A user who had TOTP on must still be asked for it
* after upgrading: their enrolment row points at the totp plugin, and their
* secret and backup codes reach the plugin's table readable. Running it twice
* changes nothing, and without the plugin's table the enrolment still moves
* so the login fails closed.
*/
import crypto from "node:crypto";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FieldCrypto } from "../../utils/field-crypto.js";
const h = vi.hoisted(() => ({
db: null as unknown,
deks: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runTotpMigration } = await import("../../upgrade/totp-migration.js");
let sqlite: Database.Database;
function setup({ pluginTable }: { pluginTable: boolean }) {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
totp_backup_codes TEXT
);
CREATE TABLE user_second_factors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
factor_id TEXT NOT NULL,
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, plugin_id, factor_id)
);
`);
if (pluginTable) {
sqlite.exec(`
CREATE TABLE p_totp_enrollments (
user_id TEXT PRIMARY KEY NOT NULL,
secret TEXT,
pending_secret TEXT,
backup_codes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
}
h.db = drizzle(sqlite);
}
function addTotpUser(id: string, secret: string, codes: string[]) {
const dek = crypto.randomBytes(32);
h.deks.set(id, dek);
sqlite
.prepare(
"INSERT INTO users (id, username, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, 1, ?)",
)
.run(
id,
id,
FieldCrypto.encryptField(secret, dek, id, "totpSecret"),
FieldCrypto.encryptField(
JSON.stringify(codes),
dek,
id,
"totpBackupCodes",
),
);
}
const factors = () =>
sqlite
.prepare(
"SELECT user_id, plugin_id, factor_id FROM user_second_factors ORDER BY user_id",
)
.all();
beforeEach(() => {
h.deks.clear();
});
describe("runTotpMigration", () => {
it("keeps a 2.8 TOTP user behind their factor, with a readable secret", async () => {
setup({ pluginTable: true });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", ["AAAA1111", "BBBB2222"]);
addTotpUser("bob", "KRSXG5CTMVRXEZLU", []);
sqlite.exec(`INSERT INTO users (id, username) VALUES ('carol', 'carol')`);
// A8 recorded alice under core; bob only has the flag.
sqlite.exec(
`INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('alice', 'core', 'totp')`,
);
expect(await runTotpMigration()).toEqual({ factors: 2, secrets: 2 });
expect(factors()).toEqual([
{ user_id: "alice", plugin_id: "totp", factor_id: "totp" },
{ user_id: "bob", plugin_id: "totp", factor_id: "totp" },
]);
const alice = sqlite
.prepare("SELECT * FROM p_totp_enrollments WHERE user_id = 'alice'")
.get() as Record<string, string>;
expect(alice.secret).toBe("sysenc:JBSWY3DPEHPK3PXP");
expect(alice.backup_codes).toBe(
`sysenc:${JSON.stringify(["AAAA1111", "BBBB2222"])}`,
);
expect(alice.pending_secret).toBeNull();
expect(
sqlite
.prepare(
"SELECT totp_enabled, totp_secret, totp_backup_codes FROM users WHERE id = 'alice'",
)
.get(),
).toEqual({ totp_enabled: 0, totp_secret: null, totp_backup_codes: null });
// A second boot changes nothing.
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
expect(factors()).toHaveLength(2);
expect(
sqlite.prepare("SELECT COUNT(*) AS n FROM p_totp_enrollments").get(),
).toEqual({ n: 2 });
});
it("moves the enrolment but keeps the columns while the plugin table is missing", async () => {
setup({ pluginTable: false });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", ["AAAA1111"]);
expect(await runTotpMigration()).toEqual({ factors: 1, secrets: 0 });
expect(factors()).toEqual([
{ user_id: "alice", plugin_id: "totp", factor_id: "totp" },
]);
const row = sqlite
.prepare("SELECT totp_enabled, totp_secret FROM users WHERE id = 'alice'")
.get() as { totp_enabled: number; totp_secret: string };
expect(row.totp_enabled).toBe(1);
expect(row.totp_secret).toBeTruthy();
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
});
it("skips a user whose data key is unavailable and retries later", async () => {
setup({ pluginTable: true });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", []);
const dek = h.deks.get("alice")!;
h.deks.delete("alice");
expect(await runTotpMigration()).toEqual({ factors: 1, secrets: 0 });
h.deks.set("alice", dek);
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 1 });
});
it("moves only the named user when a login opens their key", async () => {
setup({ pluginTable: true });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", []);
addTotpUser("bob", "KRSXG5CTMVRXEZLU", []);
expect(await runTotpMigration("alice")).toEqual({ factors: 1, secrets: 1 });
const moved = sqlite
.prepare("SELECT user_id FROM p_totp_enrollments")
.all();
expect(moved).toEqual([{ user_id: "alice" }]);
});
it("drops a duplicate core row when the plugin row already exists", async () => {
setup({ pluginTable: true });
sqlite.exec(`
INSERT INTO users (id, username) VALUES ('dave', 'dave');
INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('dave', 'core', 'totp');
INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('dave', 'totp', 'totp');
`);
await runTotpMigration();
expect(factors()).toEqual([
{ user_id: "dave", plugin_id: "totp", factor_id: "totp" },
]);
});
it("does nothing on a fresh install without the legacy columns", async () => {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL);
CREATE TABLE user_second_factors (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL,
plugin_id TEXT NOT NULL, factor_id TEXT NOT NULL,
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
h.db = drizzle(sqlite);
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
});
});
@@ -0,0 +1,185 @@
/**
* The tunnel host-columns migration.
*
* An upgrade must be lossless: a host with tunnels on and a saved tunnel list
* must keep both once the ssh_data columns are dropped. Running it twice
* must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_tunnel: boolean;
tunnel_connections: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) => row.enable_tunnel || row.tunnel_connections !== null,
),
}),
}));
const { runTunnelsSettingsMigration } =
await import("../../upgrade/tunnels-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
const tunnel = {
scope: "s2s",
mode: "local",
sourcePort: 8080,
endpointHost: "db",
endpointPort: 5432,
maxRetries: 3,
retryInterval: 10,
autoStart: true,
};
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runTunnelsSettingsMigration", () => {
it("moves a host's switch and saved tunnels into plugin settings", async () => {
hostRows.push({
id: 1,
enable_tunnel: true,
tunnel_connections: JSON.stringify([tunnel]),
});
const result = await runTunnelsSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableTunnel")).toBe(true);
expect(storedValue(1, "tunnelConnections")).toEqual([tunnel]);
expect(settingsRows.every((row) => row.pluginId === "tunnels")).toBe(true);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("keeps a disabled host's saved tunnels and its disabled switch", async () => {
hostRows.push({
id: 3,
enable_tunnel: false,
tunnel_connections: JSON.stringify([tunnel]),
});
await runTunnelsSettingsMigration();
expect(storedValue(3, "enableTunnel")).toBe(false);
expect(storedValue(3, "tunnelConnections")).toEqual([tunnel]);
});
it("stores an unreadable tunnel list as empty rather than failing", async () => {
hostRows.push({ id: 4, enable_tunnel: true, tunnel_connections: "{nope" });
const result = await runTunnelsSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "tunnelConnections")).toEqual([]);
});
it("does nothing on a fresh install", async () => {
const result = await runTunnelsSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_tunnel: true, tunnel_connections: null });
await runTunnelsSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runTunnelsSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({
id: 5,
enable_tunnel: true,
tunnel_connections: JSON.stringify([tunnel]),
});
await pluginSettingsRepository.set(
"tunnels",
"host",
"5",
"enableTunnel",
"false",
);
const result = await runTunnelsSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableTunnel")).toBe(false);
expect(storedValue(5, "tunnelConnections")).toBeUndefined();
});
});
@@ -0,0 +1,152 @@
/**
* The Vault host settings migration.
*
* Every host's ssh_data.vault_profile_id must come through into the vault
* plugin's profileId host setting, and an install that used Vault keeps the
* 2.8 redirect URI. Running it twice must change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
legacyProfiles: 0,
adoptedProfiles: 0,
pluginInstalled: true,
columnGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "vault" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows.push({ scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: unknown) => {
const text = JSON.stringify(query);
if (text.includes("vault_profile_id")) {
if (state.columnGone) throw new Error("no such column");
return state.hostRows.filter((row) => row.vault_profile_id != null);
}
const count = text.includes("p_vault_profiles")
? state.adoptedProfiles
: state.legacyProfiles;
return Array.from({ length: Math.min(count, 1) }, () => ({ "1": 1 }));
},
runStatement: async () => {},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runVaultSettingsMigration } from "../../upgrade/vault-settings-migration.js";
const setting = (scopeId: string | null, key: string) =>
state.pluginRows.find((row) => row.scopeId === scopeId && row.key === key)
?.value;
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.legacyProfiles = 0;
state.adoptedProfiles = 0;
state.pluginInstalled = true;
state.columnGone = false;
});
describe("runVaultSettingsMigration", () => {
it("moves every host's profile, keeps the old redirect URI, and a second run changes nothing", async () => {
state.hostRows = [
{ id: 1, vault_profile_id: 7 },
{ id: 2, vault_profile_id: null },
{ id: 3, vault_profile_id: "9" },
];
state.adoptedProfiles = 2;
const first = await runVaultSettingsMigration();
expect(first).toEqual({
hostsMoved: 2,
hostsSkipped: 0,
legacyCallback: true,
});
expect(setting("1", "profileId")).toBe("7");
expect(setting("2", "profileId")).toBeUndefined();
expect(setting("3", "profileId")).toBe("9");
expect(setting(null, "legacyCallback")).toBe("true");
const rows = state.pluginRows.length;
const second = await runVaultSettingsMigration();
expect(second).toEqual({
hostsMoved: 0,
hostsSkipped: 2,
legacyCallback: false,
});
expect(state.pluginRows).toHaveLength(rows);
});
it("keeps a host's value already set in the plugin", async () => {
state.pluginRows.push({
scope: "host",
scopeId: "1",
key: "profileId",
value: "4",
});
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
await runVaultSettingsMigration();
expect(setting("1", "profileId")).toBe("4");
});
it("keeps the old redirect URI for profiles the plugin has not adopted yet", async () => {
state.legacyProfiles = 1;
const result = await runVaultSettingsMigration();
expect(result.legacyCallback).toBe(true);
});
it("uses the new redirect URI on a fresh install, and profiles made later do not change that", async () => {
state.columnGone = true;
const first = await runVaultSettingsMigration();
expect(first.legacyCallback).toBe(false);
expect(setting(null, "legacyCallback")).toBe("false");
state.adoptedProfiles = 3;
await runVaultSettingsMigration();
expect(setting(null, "legacyCallback")).toBe("false");
});
it("waits for the plugin row", async () => {
state.pluginInstalled = false;
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
await runVaultSettingsMigration();
expect(state.pluginRows).toHaveLength(0);
});
});
@@ -0,0 +1,190 @@
/**
* The Wake-on-LAN host settings migration.
*
* An upgrade must be lossless: every host with a MAC address or a broadcast
* address must come through into the wake-on-lan plugin's host settings.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
pluginInstalled: true,
columnsGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "wake-on-lan" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
);
if (existing) {
existing.value = value;
return;
}
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async () => {
if (state.columnsGone) throw new Error("no such column: mac_address");
return state.hostRows;
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
wakeOnLanSettingsFromRow,
runWakeOnLanSettingsMigration,
} from "../../upgrade/wake-on-lan-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
state.pluginRows
.filter((row) => row.scope === "host" && row.scopeId === String(hostId))
.map((row) => [row.key, JSON.parse(row.value ?? "null")]),
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.pluginInstalled = true;
state.columnsGone = false;
});
describe("wakeOnLanSettingsFromRow", () => {
it("carries over a MAC address", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: "aa:bb:cc:dd:ee:ff",
wol_broadcast_address: null,
}),
).toEqual({ macAddress: "aa:bb:cc:dd:ee:ff" });
});
it("carries over a broadcast address alongside the MAC address", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: "aa:bb:cc:dd:ee:ff",
wol_broadcast_address: "192.168.1.255",
}),
).toEqual({
macAddress: "aa:bb:cc:dd:ee:ff",
broadcastAddress: "192.168.1.255",
});
});
it("produces nothing for a host with neither set", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: null,
wol_broadcast_address: null,
}),
).toEqual({});
});
});
describe("runWakeOnLanSettingsMigration", () => {
it("moves every host's wake-on-lan settings and skips hosts with nothing set", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
{
id: 2,
mac_address: "11:22:33:44:55:66",
wol_broadcast_address: "192.168.1.255",
},
{ id: 3, mac_address: null, wol_broadcast_address: null },
];
const result = await runWakeOnLanSettingsMigration();
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0 });
expect(hostValues(1)).toEqual({ macAddress: "aa:bb:cc:dd:ee:ff" });
expect(hostValues(2)).toEqual({
macAddress: "11:22:33:44:55:66",
broadcastAddress: "192.168.1.255",
});
expect(hostValues(3)).toEqual({});
});
it("changes nothing when run twice", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
await runWakeOnLanSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const second = await runWakeOnLanSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1 });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("keeps a value already saved in the plugin", async () => {
state.pluginRows.push({
pluginId: "wake-on-lan",
scope: "host",
scopeId: "1",
key: "macAddress",
value: JSON.stringify("11:11:11:11:11:11"),
});
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
await runWakeOnLanSettingsMigration();
expect(hostValues(1)).toEqual({ macAddress: "11:11:11:11:11:11" });
});
it("does nothing before the plugin row exists or once the columns are gone", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
state.pluginInstalled = false;
expect(await runWakeOnLanSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
state.pluginInstalled = true;
state.columnsGone = true;
expect(await runWakeOnLanSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,161 @@
/**
* The Warpgate host settings migration.
*
* Every host with use_warpgate on, whatever shape the engine returned it in,
* and every pre-2.7 host still on auth type "warpgate", must come through
* into the warpgate plugin's useWarpgate host setting. Running it twice must
* change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
statements: [] as string[],
pluginInstalled: true,
columnGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "warpgate" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows.push({ scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks?: unknown[] }) => {
const text = JSON.stringify(query);
if (text.includes("use_warpgate") && state.columnGone) {
throw new Error("no such column: use_warpgate");
}
return state.hostRows.filter(
(row) => !text.includes("WHERE") || row.auth_type === "warpgate",
);
},
runStatement: async (query: unknown) => {
const text = JSON.stringify(query);
state.statements.push(text);
const id = (query as { queryChunks: unknown[] }).queryChunks.find(
(chunk) => typeof chunk === "number",
);
const row = state.hostRows.find((candidate) => candidate.id === id);
if (row) row.auth_type = "none";
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
runWarpgateSettingsMigration,
usesWarpgate,
} from "../../upgrade/warpgate-settings-migration.js";
const enabled = (hostId: number) =>
state.pluginRows.some(
(row) =>
row.scopeId === String(hostId) &&
row.key === "useWarpgate" &&
row.value === "true",
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.statements = [];
state.pluginInstalled = true;
state.columnGone = false;
});
describe("usesWarpgate", () => {
it("reads the flag in every shape the engines return", () => {
for (const flag of [true, 1, "1", "true"]) {
expect(
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
).toBe(true);
}
for (const flag of [false, 0, "0", null]) {
expect(
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
).toBe(false);
}
expect(usesWarpgate({ id: 1, auth_type: "warpgate" })).toBe(true);
});
});
describe("runWarpgateSettingsMigration", () => {
it("moves every flagged host and fixes the old auth type, and a second run changes nothing", async () => {
state.hostRows = [
{ id: 1, use_warpgate: 1, auth_type: "password" },
{ id: 2, use_warpgate: 0, auth_type: "key" },
{ id: 3, use_warpgate: true, auth_type: "none" },
{ id: 4, use_warpgate: 0, auth_type: "warpgate" },
];
const first = await runWarpgateSettingsMigration();
expect(first).toEqual({
hostsMoved: 3,
hostsSkipped: 0,
authTypesFixed: 1,
});
expect(enabled(1)).toBe(true);
expect(enabled(2)).toBe(false);
expect(enabled(3)).toBe(true);
expect(enabled(4)).toBe(true);
expect(state.hostRows[3].auth_type).toBe("none");
const rows = state.pluginRows.length;
const second = await runWarpgateSettingsMigration();
// Host 4 is on auth type "none" now, so only the flagged two are seen.
expect(second).toEqual({
hostsMoved: 0,
hostsSkipped: 2,
authTypesFixed: 0,
});
expect(state.pluginRows).toHaveLength(rows);
});
it("still fixes old auth types on an install without the column", async () => {
state.columnGone = true;
state.hostRows = [{ id: 5, auth_type: "warpgate" }];
await runWarpgateSettingsMigration();
expect(enabled(5)).toBe(true);
expect(state.hostRows[0].auth_type).toBe("none");
});
it("waits for the plugin row", async () => {
state.pluginInstalled = false;
state.hostRows = [{ id: 1, use_warpgate: 1, auth_type: "password" }];
await runWarpgateSettingsMigration();
expect(state.pluginRows).toHaveLength(0);
});
});
@@ -0,0 +1,184 @@
/**
* The web endpoint host-columns migration.
*
* An upgrade must be lossless: a host with web endpoints on and a saved
* config must keep both once the ssh_data columns are dropped. Running it
* twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_web_ui: boolean;
web_ui_config: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter((row) => row.enable_web_ui || row.web_ui_config !== null),
}),
}));
const { runWebEndpointSettingsMigration } =
await import("../../upgrade/web-endpoint-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
const endpoint = {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
};
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runWebEndpointSettingsMigration", () => {
it("moves a host's switch and saved config into plugin settings", async () => {
hostRows.push({
id: 1,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableWebUi")).toBe(true);
expect(storedValue(1, "webUiConfig")).toEqual({ endpoints: [endpoint] });
expect(settingsRows.every((row) => row.pluginId === "web-endpoint")).toBe(
true,
);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("keeps a disabled host's saved config and its disabled switch", async () => {
hostRows.push({
id: 3,
enable_web_ui: false,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await runWebEndpointSettingsMigration();
expect(storedValue(3, "enableWebUi")).toBe(false);
expect(storedValue(3, "webUiConfig")).toEqual({ endpoints: [endpoint] });
});
it("stores an unreadable config as null rather than failing", async () => {
hostRows.push({ id: 4, enable_web_ui: true, web_ui_config: "{nope" });
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "webUiConfig")).toBeNull();
});
it("does nothing on a fresh install", async () => {
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_web_ui: true, web_ui_config: null });
await runWebEndpointSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runWebEndpointSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({
id: 5,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await pluginSettingsRepository.set(
"web-endpoint",
"host",
"5",
"enableWebUi",
"false",
);
const result = await runWebEndpointSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableWebUi")).toBe(false);
expect(storedValue(5, "webUiConfig")).toBeUndefined();
});
});