mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
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:
4405 files changed
+1341026
-289722
No files matched your search
@@ -1,204 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findCredentialByIdForUser: async () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { SSHAuthManager } from "../../hosts/auth-manager.js";
|
||||
|
||||
function createManager() {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
|
||||
const manager = new SSHAuthManager({
|
||||
userId: "user-1",
|
||||
ws,
|
||||
hostId: 1,
|
||||
isKeyboardInteractive: false,
|
||||
keyboardInteractiveResponded: false,
|
||||
keyboardInteractiveFinish: null,
|
||||
totpPromptSent: false,
|
||||
warpgateAuthPromptSent: false,
|
||||
totpTimeout: null,
|
||||
warpgateAuthTimeout: null,
|
||||
totpAttempts: 0,
|
||||
});
|
||||
return { manager, sent };
|
||||
}
|
||||
|
||||
describe("SSHAuthManager.handleKeyboardInteractive", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("routes a TOTP verification prompt to the totp flow", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Verification code: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "TOTP verification required",
|
||||
},
|
||||
},
|
||||
{ type: "totp_required", prompt: "Verification code: " },
|
||||
]);
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards echo:true for a JumpCloud-style push/TOTP menu prompt", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Password authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "password_required",
|
||||
prompt: "Choose [1] Push, or [2] TOTP: ",
|
||||
echo: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("silently auto-answers a plain password prompt when a stored password exists", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Password: ", echo: false }],
|
||||
finish,
|
||||
{ username: "root", password: "hunter2", authType: "password" },
|
||||
);
|
||||
|
||||
expect(finish).toHaveBeenCalledWith(["hunter2"]);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it("prompts the user for a push-confirm prompt and accepts an empty response", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Press enter to send Push request: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Password authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "password_required",
|
||||
prompt: "Press enter to send Push request: ",
|
||||
echo: true,
|
||||
},
|
||||
]);
|
||||
|
||||
manager.context.keyboardInteractiveFinish?.([""]);
|
||||
|
||||
expect(finish).toHaveBeenCalledWith([""]);
|
||||
});
|
||||
|
||||
it("uses a longer timeout for push-style prompts than generic prompts", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Press enter to send Push request: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(180001);
|
||||
expect(sent.some((m) => m.type === "error")).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(120000);
|
||||
expect(sent.some((m) => m.type === "error")).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes Warpgate prompts to the warpgate flow, not the generic path", () => {
|
||||
const { manager, sent } = createManager();
|
||||
const finish = vi.fn();
|
||||
|
||||
manager.handleKeyboardInteractive(
|
||||
"Warpgate Authentication",
|
||||
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
|
||||
"",
|
||||
[{ prompt: "Press enter once done: ", echo: true }],
|
||||
finish,
|
||||
{ username: "root", authType: "none" },
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage: "auth",
|
||||
level: "info",
|
||||
message: "Warpgate authentication required",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "warpgate_auth_required",
|
||||
url: "https://warpgate.example.com/auth",
|
||||
securityKey: "AB12",
|
||||
instructions:
|
||||
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
import {
|
||||
createWebSocketDuplex,
|
||||
waitForWebSocketOpen,
|
||||
} from "../../hosts/cloudflare-websocket.js";
|
||||
|
||||
function setupSocket() {
|
||||
const socket = new EventEmitter() as EventEmitter & {
|
||||
terminate: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
socket.terminate = vi.fn();
|
||||
socket.send = vi.fn();
|
||||
return socket as unknown as WebSocket;
|
||||
}
|
||||
|
||||
describe("waitForWebSocketOpen", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("clears listeners and leaves an opened socket alive", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = setupSocket();
|
||||
const result = waitForWebSocketOpen(socket, 30_000);
|
||||
|
||||
socket.emit("open");
|
||||
|
||||
await expect(result).resolves.toBeUndefined();
|
||||
expect(socket.terminate).not.toHaveBeenCalled();
|
||||
expect(socket.listenerCount("open")).toBe(0);
|
||||
expect(socket.listenerCount("error")).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("terminates a socket that does not open before the deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = setupSocket();
|
||||
const result = waitForWebSocketOpen(socket, 30_000);
|
||||
const rejection = expect(result).rejects.toThrow(
|
||||
"Cloudflare tunnel timeout",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
await rejection;
|
||||
expect(socket.terminate).toHaveBeenCalledOnce();
|
||||
expect(socket.listenerCount("open")).toBe(0);
|
||||
expect(socket.listenerCount("error")).toBe(0);
|
||||
});
|
||||
|
||||
it("terminates the socket when the SSH duplex is destroyed", () => {
|
||||
const socket = setupSocket();
|
||||
const duplex = createWebSocketDuplex(socket);
|
||||
|
||||
duplex.destroy();
|
||||
|
||||
expect(socket.terminate).toHaveBeenCalledOnce();
|
||||
expect(socket.listenerCount("message")).toBe(0);
|
||||
expect(socket.listenerCount("close")).toBe(0);
|
||||
expect(socket.listenerCount("error")).toBe(0);
|
||||
});
|
||||
|
||||
it("handles established socket errors without an unhandled event", () => {
|
||||
const socket = setupSocket();
|
||||
const duplex = createWebSocketDuplex(socket);
|
||||
|
||||
socket.emit("error", new Error("connection lost"));
|
||||
|
||||
expect(duplex.destroyed).toBe(true);
|
||||
expect(socket.terminate).toHaveBeenCalledOnce();
|
||||
expect(socket.listenerCount("error")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
listener: null as ((roomId: string, message: object) => void) | null,
|
||||
publish: vi.fn(async () => undefined),
|
||||
updatePresence: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/collab/runtime-store.js", () => ({
|
||||
collabRuntimeStore: {
|
||||
onEvent: (listener: (roomId: string, message: object) => void) => {
|
||||
runtime.listener = listener;
|
||||
},
|
||||
publish: runtime.publish,
|
||||
updatePresence: runtime.updatePresence,
|
||||
onlineUsers: async (
|
||||
_roomId: string,
|
||||
users: Array<{ userId: string; username: string }>,
|
||||
) => users,
|
||||
},
|
||||
}));
|
||||
|
||||
import { collabRoomHub } from "../../../hosts/collab/room-hub.js";
|
||||
|
||||
function fakeWs(open = true): WebSocket {
|
||||
return {
|
||||
OPEN: 1,
|
||||
readyState: open ? 1 : 3,
|
||||
send: vi.fn(),
|
||||
} as unknown as WebSocket;
|
||||
}
|
||||
|
||||
describe("collabRoomHub", () => {
|
||||
it("announces the online list on subscribe and unsubscribe, deduplicated per user", async () => {
|
||||
const a1 = fakeWs();
|
||||
const a2 = fakeWs();
|
||||
const b = fakeWs();
|
||||
collabRoomHub.subscribe("room-1", { ws: a1, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-1", { ws: a2, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-1", { ws: b, userId: "b", username: "B" });
|
||||
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
{ userId: "a", username: "A" },
|
||||
{ userId: "b", username: "B" },
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => expect(b.send).toHaveBeenCalled());
|
||||
const last = JSON.parse(
|
||||
(b.send as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0] as string,
|
||||
);
|
||||
expect(last).toEqual({
|
||||
type: "collab_online",
|
||||
roomId: "room-1",
|
||||
users: [
|
||||
{ userId: "a", username: "A" },
|
||||
{ userId: "b", username: "B" },
|
||||
],
|
||||
});
|
||||
|
||||
collabRoomHub.unsubscribe(a1);
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toHaveLength(2);
|
||||
collabRoomHub.unsubscribe(a2);
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
{ userId: "b", username: "B" },
|
||||
]);
|
||||
collabRoomHub.unsubscribe(b);
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("subscribing the same socket twice keeps one subscription", async () => {
|
||||
const ws = fakeWs();
|
||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||
await vi.waitFor(() =>
|
||||
expect((ws.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1),
|
||||
);
|
||||
collabRoomHub.unsubscribe(ws);
|
||||
});
|
||||
|
||||
it("broadcast skips closed sockets and rooms nobody watches", () => {
|
||||
const open = fakeWs();
|
||||
const closed = fakeWs(false);
|
||||
collabRoomHub.subscribe("room-3", { ws: open, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-3", {
|
||||
ws: closed,
|
||||
userId: "b",
|
||||
username: "B",
|
||||
});
|
||||
(open.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
(closed.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
collabRoomHub.broadcast("room-3", { type: "collab_stage_changed" });
|
||||
collabRoomHub.broadcast("nobody", { type: "collab_stage_changed" });
|
||||
|
||||
expect(open.send).toHaveBeenCalledTimes(1);
|
||||
expect(closed.send).not.toHaveBeenCalled();
|
||||
collabRoomHub.unsubscribe(open);
|
||||
collabRoomHub.unsubscribe(closed);
|
||||
});
|
||||
|
||||
it("fans out remote Redis events but keeps internal events server-side", () => {
|
||||
const ws = fakeWs();
|
||||
collabRoomHub.subscribe("room-4", { ws, userId: "a", username: "A" });
|
||||
(ws.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
runtime.listener?.("room-4", { type: "collab_members_changed" });
|
||||
runtime.listener?.("room-4", { type: "collab_internal_stage_revoked" });
|
||||
|
||||
expect(ws.send).toHaveBeenCalledTimes(1);
|
||||
collabRoomHub.unsubscribe(ws);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
roomsByShare: new Map<string, { id: string }>(),
|
||||
members: new Set<string>(), // `${roomId}:${userId}`
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentCollabRoomRepository: () => ({
|
||||
findByStageShareId: async (shareId: string) =>
|
||||
state.roomsByShare.get(shareId) ?? null,
|
||||
findMember: async (roomId: string, userId: string) =>
|
||||
state.members.has(`${roomId}:${userId}`) ? { roomId, userId } : null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { canJoinRoomStageShare } =
|
||||
await import("../../../hosts/collab/room-share-access.js");
|
||||
|
||||
describe("canJoinRoomStageShare", () => {
|
||||
beforeEach(() => {
|
||||
state.roomsByShare.clear();
|
||||
state.members.clear();
|
||||
});
|
||||
|
||||
it("admits members of the live room whose stage is this share", async () => {
|
||||
state.roomsByShare.set("share-1", { id: "room-1" });
|
||||
state.members.add("room-1:alice");
|
||||
await expect(canJoinRoomStageShare("share-1", "alice")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-members and shares that are not a room stage", async () => {
|
||||
state.roomsByShare.set("share-1", { id: "room-1" });
|
||||
await expect(canJoinRoomStageShare("share-1", "mallory")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(canJoinRoomStageShare("share-9", "alice")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,693 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
type Room = {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
guestLinkToken: string | null;
|
||||
createdAt: string;
|
||||
endedAt: string | null;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "host-1",
|
||||
rooms: new Map<string, Room>(),
|
||||
members: new Map<
|
||||
string,
|
||||
{ roomId: string; userId: string; roomRole: string }
|
||||
>(),
|
||||
shares: new Map<string, Record<string, unknown>>(),
|
||||
users: new Set<string>(["host-1", "alice", "bob"]),
|
||||
roles: new Map<number, string[]>([[7, ["alice", "carol"]]]),
|
||||
sharingEnabled: true,
|
||||
liveOwned: new Map<string, string>(), // sessionId -> owner
|
||||
broadcasts: [] as Array<Record<string, unknown>>,
|
||||
control: [] as Array<unknown[]>,
|
||||
controllers: new Map<string, string>(),
|
||||
requests: new Map<
|
||||
string,
|
||||
Map<string, { userId: string; username: string; requestedAt: string }>
|
||||
>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => undefined),
|
||||
getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
|
||||
GuacamoleTokenService: {
|
||||
getInstance: () => ({
|
||||
createJoinToken: (id: string, readOnly: boolean) =>
|
||||
`join:${id}:${readOnly}`,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/collab/room-hub.js", () => ({
|
||||
collabRoomHub: {
|
||||
broadcast: (roomId: string, message: Record<string, unknown>) => {
|
||||
state.broadcasts.push({ roomId, ...message });
|
||||
},
|
||||
onlineUsers: () => [],
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/collab/runtime-store.js", () => ({
|
||||
collabRuntimeStore: {
|
||||
onEvent: vi.fn(),
|
||||
publish: vi.fn(async () => undefined),
|
||||
getController: async (roomId: string) =>
|
||||
state.controllers.get(roomId) ?? null,
|
||||
setController: async (roomId: string, userId: string | null) => {
|
||||
if (userId) state.controllers.set(roomId, userId);
|
||||
else state.controllers.delete(roomId);
|
||||
},
|
||||
listRequests: async (roomId: string) =>
|
||||
Array.from(state.requests.get(roomId)?.values() ?? []).sort((a, b) =>
|
||||
a.requestedAt.localeCompare(b.requestedAt),
|
||||
),
|
||||
upsertRequest: async (
|
||||
roomId: string,
|
||||
request: { userId: string; username: string; requestedAt: string },
|
||||
) => {
|
||||
let requests = state.requests.get(roomId);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
state.requests.set(roomId, requests);
|
||||
}
|
||||
requests.set(request.userId, request);
|
||||
},
|
||||
removeRequest: async (roomId: string, userId: string) => {
|
||||
state.requests.get(roomId)?.delete(userId);
|
||||
},
|
||||
clearRequests: async (roomId: string) => {
|
||||
state.requests.delete(roomId);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
setRoomShareControl: (...args: unknown[]) => {
|
||||
state.control.push(args);
|
||||
},
|
||||
disconnectShareParticipants: vi.fn(() => 0),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/session-sharing/live-sessions.js", () => ({
|
||||
isSharingEnabledForHost: async () => ({
|
||||
enabled: state.sharingEnabled,
|
||||
hostOwnerId: "host-1",
|
||||
}),
|
||||
isLiveSessionOwnedBy: (_p: string, sessionId: string, userId: string) =>
|
||||
state.liveOwned.get(sessionId) === userId,
|
||||
isLiveSession: (_p: string, sessionId: string) =>
|
||||
state.liveOwned.has(sessionId),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentCollabRoomRepository: () => ({
|
||||
createRoom: async (
|
||||
input: Omit<
|
||||
Room,
|
||||
| "presenterUserId"
|
||||
| "stageProtocol"
|
||||
| "stageHostId"
|
||||
| "stageShareId"
|
||||
| "guestLinkToken"
|
||||
| "createdAt"
|
||||
| "endedAt"
|
||||
>,
|
||||
) => {
|
||||
const room: Room = {
|
||||
...input,
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
guestLinkToken: null,
|
||||
createdAt: "2026-08-25T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
};
|
||||
state.rooms.set(room.id, room);
|
||||
return room;
|
||||
},
|
||||
findById: async (id: string) => state.rooms.get(id) ?? null,
|
||||
findByGuestToken: async (token: string) =>
|
||||
[...state.rooms.values()].find(
|
||||
(r) => r.guestLinkToken === token && !r.endedAt,
|
||||
) ?? null,
|
||||
setGuestToken: async (roomId: string, token: string | null) => {
|
||||
state.rooms.get(roomId)!.guestLinkToken = token;
|
||||
},
|
||||
listForUser: async (userId: string) =>
|
||||
[...state.members.values()]
|
||||
.filter((m) => m.userId === userId)
|
||||
.map((m) => state.rooms.get(m.roomId)!)
|
||||
.filter((r) => !r.endedAt),
|
||||
findMember: async (roomId: string, userId: string) =>
|
||||
state.members.get(`${roomId}:${userId}`) ?? null,
|
||||
addMember: async (input: {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
roomRole: string;
|
||||
}) => {
|
||||
const key = `${input.roomId}:${input.userId}`;
|
||||
if (state.members.has(key)) return false;
|
||||
state.members.set(key, input);
|
||||
return true;
|
||||
},
|
||||
removeMember: async (roomId: string, userId: string) => {
|
||||
state.members.delete(`${roomId}:${userId}`);
|
||||
},
|
||||
listMembers: async (roomId: string) =>
|
||||
[...state.members.values()]
|
||||
.filter((m) => m.roomId === roomId)
|
||||
.map((m) => ({ ...m, username: m.userId, createdAt: "" })),
|
||||
updateStage: async (roomId: string, stage: Partial<Room>) => {
|
||||
Object.assign(state.rooms.get(roomId)!, stage);
|
||||
},
|
||||
replaceStage: async (
|
||||
roomId: string,
|
||||
expectedShareId: string | null,
|
||||
stage: Partial<Room>,
|
||||
) => {
|
||||
const room = state.rooms.get(roomId)!;
|
||||
if (room.stageShareId !== expectedShareId) return false;
|
||||
Object.assign(room, stage);
|
||||
return true;
|
||||
},
|
||||
clearStage: async (roomId: string) => {
|
||||
Object.assign(state.rooms.get(roomId)!, {
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
},
|
||||
endRoom: async (roomId: string) => {
|
||||
Object.assign(state.rooms.get(roomId)!, {
|
||||
endedAt: "2026-08-25T01:00:00.000Z",
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
},
|
||||
}),
|
||||
createCurrentSessionShareRepository: () => ({
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
const row = { ...input, revokedAt: null };
|
||||
state.shares.set(input.id as string, row);
|
||||
return row;
|
||||
},
|
||||
findActiveById: async (id: string) => {
|
||||
const share = state.shares.get(id);
|
||||
return share && !share.revokedAt ? share : null;
|
||||
},
|
||||
revokeAsAdmin: async (id: string) => {
|
||||
const share = state.shares.get(id);
|
||||
if (!share) return false;
|
||||
share.revokedAt = "now";
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => (state.users.has(id) ? { id } : null),
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
findRoleById: async (id: number) => (state.roles.has(id) ? { id } : null),
|
||||
listRoleUserIds: async (id: number) => state.roles.get(id) ?? [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: router } = await import("../../../hosts/collab/routes.js");
|
||||
const { getStageController } =
|
||||
await import("../../../hosts/collab/stage-control.js");
|
||||
|
||||
type RouteLayer = {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: {
|
||||
handle: (req: Request, res: Response, next: () => void) => unknown;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
} = {},
|
||||
) {
|
||||
const layers = (router as unknown as { stack: RouteLayer[] }).stack;
|
||||
const layer = layers.find(
|
||||
(l) => l.route?.path === path && l.route.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
const req = {
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
headers: {},
|
||||
ip: overrides.ip ?? "127.0.0.1",
|
||||
socket: { remoteAddress: overrides.ip ?? "127.0.0.1" },
|
||||
} as unknown as Request;
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
for (const handler of layer.route.stack) {
|
||||
let calledNext = false;
|
||||
await handler.handle(req, res as unknown as Response, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
if (!calledNext) break;
|
||||
}
|
||||
return res as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
async function as(userId: string, run: () => Promise<unknown>) {
|
||||
const previous = state.currentUserId;
|
||||
state.currentUserId = userId;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
state.currentUserId = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRoom(persistent = false): Promise<string> {
|
||||
const response = await invoke("post", "/rooms", {
|
||||
body: { name: "Standup", persistent },
|
||||
});
|
||||
return (response.jsonBody!.room as Room).id;
|
||||
}
|
||||
|
||||
async function invite(roomId: string, userIds: string[]) {
|
||||
return invoke("post", "/rooms/:id/members", {
|
||||
params: { id: roomId },
|
||||
body: { userIds },
|
||||
});
|
||||
}
|
||||
|
||||
async function present(roomId: string, sessionId: string, protocol = "ssh") {
|
||||
return invoke("post", "/rooms/:id/present", {
|
||||
params: { id: roomId },
|
||||
body: { protocol, sessionId, hostId: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("collab room routes", () => {
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "host-1";
|
||||
state.rooms.clear();
|
||||
state.members.clear();
|
||||
state.shares.clear();
|
||||
state.liveOwned.clear();
|
||||
state.broadcasts.length = 0;
|
||||
state.control.length = 0;
|
||||
state.controllers.clear();
|
||||
state.requests.clear();
|
||||
state.sharingEnabled = true;
|
||||
});
|
||||
|
||||
it("creating a room makes the creator its host and lists it for them only", async () => {
|
||||
const roomId = await createRoom();
|
||||
expect(state.members.get(`${roomId}:host-1`)?.roomRole).toBe("host");
|
||||
|
||||
const mine = await invoke("get", "/rooms");
|
||||
expect((mine.jsonBody!.rooms as Room[]).map((r) => r.id)).toEqual([roomId]);
|
||||
|
||||
const other = await as("alice", () =>
|
||||
invoke("get", "/rooms/:id", { params: { id: roomId } }),
|
||||
);
|
||||
expect((other as { statusCode: number }).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("never exposes the guest bearer token in room responses", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
state.rooms.get(roomId)!.guestLinkToken = "secret-token";
|
||||
|
||||
const list = await as("alice", () => invoke("get", "/rooms"));
|
||||
const listedRoom = (list as Awaited<ReturnType<typeof invoke>>).jsonBody!
|
||||
.rooms as Array<Record<string, unknown>>;
|
||||
expect(listedRoom[0]).not.toHaveProperty("guestLinkToken");
|
||||
expect(listedRoom[0]).toHaveProperty("guestLinkEnabled", true);
|
||||
|
||||
const detail = await as("alice", () =>
|
||||
invoke("get", "/rooms/:id", { params: { id: roomId } }),
|
||||
);
|
||||
const room = (detail as Awaited<ReturnType<typeof invoke>>).jsonBody!
|
||||
.room as Record<string, unknown>;
|
||||
expect(room).not.toHaveProperty("guestLinkToken");
|
||||
expect(room).toHaveProperty("guestLinkEnabled", true);
|
||||
});
|
||||
|
||||
it("rejects an empty or oversized room name", async () => {
|
||||
expect(
|
||||
(await invoke("post", "/rooms", { body: { name: " " } })).statusCode,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(await invoke("post", "/rooms", { body: { name: "x".repeat(121) } }))
|
||||
.statusCode,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("only the host invites; roles expand to their members; unknown users 404", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
const byMember = await as("alice", () => invite(roomId, ["bob"]));
|
||||
expect((byMember as { statusCode: number }).statusCode).toBe(403);
|
||||
|
||||
expect((await invite(roomId, ["nobody"])).statusCode).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await invoke("post", "/rooms/:id/members", {
|
||||
params: { id: roomId },
|
||||
body: {},
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(400);
|
||||
|
||||
const byRole = await invoke("post", "/rooms/:id/members", {
|
||||
params: { id: roomId },
|
||||
body: { roleIds: [7] },
|
||||
});
|
||||
expect(byRole.statusCode).toBe(200);
|
||||
expect(state.members.has(`${roomId}:carol`)).toBe(true);
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_members_changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("members may leave, only the host removes others, the owner is never removed", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice", "bob"]);
|
||||
|
||||
const aliceRemovesBob = await as("alice", () =>
|
||||
invoke("delete", "/rooms/:id/members/:userId", {
|
||||
params: { id: roomId, userId: "bob" },
|
||||
}),
|
||||
);
|
||||
expect((aliceRemovesBob as { statusCode: number }).statusCode).toBe(403);
|
||||
|
||||
await as("alice", () =>
|
||||
invoke("delete", "/rooms/:id/members/:userId", {
|
||||
params: { id: roomId, userId: "alice" },
|
||||
}),
|
||||
);
|
||||
expect(state.members.has(`${roomId}:alice`)).toBe(false);
|
||||
|
||||
const removeOwner = await invoke("delete", "/rooms/:id/members/:userId", {
|
||||
params: { id: roomId, userId: "host-1" },
|
||||
});
|
||||
expect(removeOwner.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("presenting validates the protocol, the sharing toggle and live-session ownership", async () => {
|
||||
const roomId = await createRoom();
|
||||
expect((await present(roomId, "s1", "ftp")).statusCode).toBe(400);
|
||||
expect((await present(roomId, "s1")).statusCode).toBe(403); // not live
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
state.sharingEnabled = false;
|
||||
expect((await present(roomId, "s1")).statusCode).toBe(403);
|
||||
state.sharingEnabled = true;
|
||||
|
||||
const ok = await present(roomId, "s1");
|
||||
expect(ok.statusCode).toBe(200);
|
||||
const room = state.rooms.get(roomId)!;
|
||||
expect(room.presenterUserId).toBe("host-1");
|
||||
expect(state.shares.get(room.stageShareId!)).toMatchObject({
|
||||
shareType: "room",
|
||||
permissionLevel: "read-only",
|
||||
sessionId: "s1",
|
||||
});
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_stage_changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("a takeover revokes the previous stage share and clears control", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
state.liveOwned.set("s2", "alice");
|
||||
await present(roomId, "s1");
|
||||
const firstShare = state.rooms.get(roomId)!.stageShareId!;
|
||||
await invoke("post", "/rooms/:id/control", {
|
||||
params: { id: roomId },
|
||||
body: { userId: "alice" },
|
||||
});
|
||||
expect(await getStageController(roomId)).toBe("alice");
|
||||
|
||||
await as("alice", () => present(roomId, "s2"));
|
||||
const room = state.rooms.get(roomId)!;
|
||||
expect(room.presenterUserId).toBe("alice");
|
||||
expect(state.shares.get(firstShare)!.revokedAt).toBeTruthy();
|
||||
expect(await getStageController(roomId)).toBeNull();
|
||||
});
|
||||
|
||||
it("stop is for the presenter or host", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice", "bob"]);
|
||||
state.liveOwned.set("s2", "alice");
|
||||
await as("alice", () => present(roomId, "s2"));
|
||||
|
||||
const bob = await as("bob", () =>
|
||||
invoke("post", "/rooms/:id/stop", { params: { id: roomId } }),
|
||||
);
|
||||
expect((bob as { statusCode: number }).statusCode).toBe(403);
|
||||
await invoke("post", "/rooms/:id/stop", { params: { id: roomId } }); // host
|
||||
expect(state.rooms.get(roomId)!.stageShareId).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps remote desktop stages read-only", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
const stageOf = (user: string) =>
|
||||
as(user, () =>
|
||||
invoke("get", "/rooms/:id/stage", { params: { id: roomId } }),
|
||||
) as Promise<{
|
||||
jsonBody: { stage: Record<string, unknown> | null };
|
||||
}>;
|
||||
|
||||
expect((await stageOf("alice")).jsonBody.stage).toBeNull();
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
const ssh = (await stageOf("alice")).jsonBody.stage!;
|
||||
expect(ssh).toMatchObject({ protocol: "ssh", sessionId: "s1" });
|
||||
expect(ssh.connectParams).toBeUndefined();
|
||||
|
||||
state.liveOwned.set("g1", "host-1");
|
||||
await present(roomId, "g1", "rdp");
|
||||
expect((await stageOf("alice")).jsonBody.stage!.connectParams).toEqual({
|
||||
token: "join:g1:true",
|
||||
});
|
||||
const control = await invoke("post", "/rooms/:id/control", {
|
||||
params: { id: roomId },
|
||||
body: { userId: "alice" },
|
||||
});
|
||||
expect(control.statusCode).toBe(400);
|
||||
expect((await stageOf("alice")).jsonBody.stage!.connectParams).toEqual({
|
||||
token: "join:g1:true",
|
||||
});
|
||||
});
|
||||
|
||||
it("a dead presenter session clears the stale stage on resolve", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
state.liveOwned.delete("s1");
|
||||
|
||||
const resolved = await as("alice", () =>
|
||||
invoke("get", "/rooms/:id/stage", { params: { id: roomId } }),
|
||||
);
|
||||
expect(
|
||||
(resolved as { jsonBody: { stage: unknown } }).jsonBody.stage,
|
||||
).toBeNull();
|
||||
expect(state.rooms.get(roomId)!.stageShareId).toBeNull();
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_stage_changed",
|
||||
stage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("control: presenter/host grant, controller releases, members may only ask", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice", "bob"]);
|
||||
const control = (user: string, target: string | null) =>
|
||||
as(user, () =>
|
||||
invoke("post", "/rooms/:id/control", {
|
||||
params: { id: roomId },
|
||||
body: { userId: target },
|
||||
}),
|
||||
) as Promise<{ statusCode: number }>;
|
||||
|
||||
expect((await control("host-1", "alice")).statusCode).toBe(400); // nothing presented
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
expect((await control("bob", "bob")).statusCode).toBe(403);
|
||||
expect((await control("host-1", "nobody")).statusCode).toBe(404);
|
||||
expect((await control("host-1", "alice")).statusCode).toBe(200);
|
||||
expect(state.control.at(-1)).toEqual([
|
||||
"s1",
|
||||
state.rooms.get(roomId)!.stageShareId,
|
||||
"alice",
|
||||
]);
|
||||
expect(state.broadcasts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "collab_control_changed",
|
||||
controllerUserId: "alice",
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await control("bob", null)).statusCode).toBe(403);
|
||||
expect((await control("alice", null)).statusCode).toBe(200);
|
||||
expect(await getStageController(roomId)).toBeNull();
|
||||
|
||||
const asked = await as("bob", () =>
|
||||
invoke("post", "/rooms/:id/control/request", { params: { id: roomId } }),
|
||||
);
|
||||
expect((asked as { statusCode: number }).statusCode).toBe(200);
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_control_requested",
|
||||
userId: "bob",
|
||||
username: "BOB",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await as("bob", () =>
|
||||
invoke("get", "/rooms/:id", { params: { id: roomId } }),
|
||||
)
|
||||
).jsonBody!.controlRequests,
|
||||
).toEqual([expect.objectContaining({ userId: "bob" })]);
|
||||
|
||||
const repeated = await as("bob", () =>
|
||||
invoke("post", "/rooms/:id/control/request", {
|
||||
params: { id: roomId },
|
||||
}),
|
||||
);
|
||||
expect((repeated as { statusCode: number }).statusCode).toBe(429);
|
||||
|
||||
const listed = await invoke("get", "/rooms/:id/control/requests", {
|
||||
params: { id: roomId },
|
||||
});
|
||||
expect(listed.jsonBody!.requests).toEqual([
|
||||
expect.objectContaining({ userId: "bob" }),
|
||||
]);
|
||||
await invoke("delete", "/rooms/:id/control/requests/:userId", {
|
||||
params: { id: roomId, userId: "bob" },
|
||||
});
|
||||
expect(state.requests.get(roomId)?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("guest link: host-only toggle, anonymous resolve follows the stage, rate limited", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
const toggle = (user: string, enabled: boolean) =>
|
||||
as(user, () =>
|
||||
invoke("post", "/rooms/:id/guest-link", {
|
||||
params: { id: roomId },
|
||||
body: { enabled },
|
||||
}),
|
||||
) as Promise<{
|
||||
statusCode: number;
|
||||
jsonBody: { guestLinkToken: string | null };
|
||||
}>;
|
||||
|
||||
expect((await toggle("alice", true)).statusCode).toBe(403);
|
||||
const { guestLinkToken } = (await toggle("host-1", true)).jsonBody;
|
||||
expect(guestLinkToken).toBeTruthy();
|
||||
|
||||
const resolve = (token: string, ip = "9.9.9.9") =>
|
||||
invoke("get", "/guest/:token", { params: { token }, ip });
|
||||
expect((await resolve("nope")).statusCode).toBe(404);
|
||||
expect((await resolve(guestLinkToken!)).jsonBody).toEqual({
|
||||
roomName: "Standup",
|
||||
stage: null,
|
||||
});
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({
|
||||
protocol: "ssh",
|
||||
wsPath: `/terminal/ws?roomGuestToken=${encodeURIComponent(guestLinkToken!)}`,
|
||||
});
|
||||
state.liveOwned.set("g1", "host-1");
|
||||
await present(roomId, "g1", "vnc");
|
||||
expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({
|
||||
protocol: "vnc",
|
||||
connectParams: { token: "join:g1:true" },
|
||||
});
|
||||
|
||||
await toggle("host-1", false);
|
||||
expect((await resolve(guestLinkToken!)).statusCode).toBe(404);
|
||||
|
||||
let last = 200;
|
||||
for (let i = 0; i < 61; i++) {
|
||||
last = (await resolve("x", "1.2.3.4")).statusCode;
|
||||
}
|
||||
expect(last).toBe(429);
|
||||
});
|
||||
|
||||
it("ending a one-off room ends it; ending a persistent room only clears the stage", async () => {
|
||||
const oneOff = await createRoom(false);
|
||||
await invite(oneOff, ["alice"]);
|
||||
const byMember = await as("alice", () =>
|
||||
invoke("post", "/rooms/:id/end", { params: { id: oneOff } }),
|
||||
);
|
||||
expect((byMember as { statusCode: number }).statusCode).toBe(403);
|
||||
await invoke("post", "/rooms/:id/end", { params: { id: oneOff } });
|
||||
expect(state.rooms.get(oneOff)!.endedAt).toBeTruthy();
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_room_ended",
|
||||
});
|
||||
|
||||
const persistent = await createRoom(true);
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(persistent, "s1");
|
||||
await invoke("post", "/rooms/:id/end", { params: { id: persistent } });
|
||||
const room = state.rooms.get(persistent)!;
|
||||
expect(room.endedAt).toBeNull();
|
||||
expect(room.stageShareId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collabRuntimeStore } from "../../../hosts/collab/runtime-store.js";
|
||||
|
||||
describe("collabRuntimeStore local fallback", () => {
|
||||
it("stores stage control and an ordered, deduplicated request queue", async () => {
|
||||
const roomId = `fallback-${crypto.randomUUID()}`;
|
||||
|
||||
await collabRuntimeStore.setController(roomId, "alice");
|
||||
expect(await collabRuntimeStore.getController(roomId)).toBe("alice");
|
||||
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "bob",
|
||||
username: "Bob",
|
||||
requestedAt: "2026-08-25T00:00:02.000Z",
|
||||
});
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "alice",
|
||||
username: "Alice",
|
||||
requestedAt: "2026-08-25T00:00:01.000Z",
|
||||
});
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "bob",
|
||||
username: "Bob",
|
||||
requestedAt: "2026-08-25T00:00:03.000Z",
|
||||
});
|
||||
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toEqual([
|
||||
expect.objectContaining({ userId: "alice" }),
|
||||
expect.objectContaining({ userId: "bob" }),
|
||||
]);
|
||||
await collabRuntimeStore.removeRequest(roomId, "alice");
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toHaveLength(1);
|
||||
|
||||
await collabRuntimeStore.clearRequests(roomId);
|
||||
await collabRuntimeStore.setController(roomId, null);
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toEqual([]);
|
||||
expect(await collabRuntimeStore.getController(roomId)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { vi } from "vitest";
|
||||
|
||||
/** Stands in for ssh2's Client: records connect() and lets a test drive events. */
|
||||
export class FakeSshClient extends EventEmitter {
|
||||
connectConfig: Record<string, unknown> | null = null;
|
||||
ended = false;
|
||||
forwardOut = vi.fn(
|
||||
(
|
||||
_srcIp: string,
|
||||
_srcPort: number,
|
||||
_dstIp: string,
|
||||
_dstPort: number,
|
||||
cb: (err: Error | undefined, stream: unknown) => void,
|
||||
) => cb(undefined, { fakeStream: true }),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
FakeSshClient.instances.push(this);
|
||||
}
|
||||
|
||||
connect(config: Record<string, unknown>) {
|
||||
this.connectConfig = config;
|
||||
const behaviour = FakeSshClient.nextBehaviour.shift() ?? "ready";
|
||||
queueMicrotask(() => {
|
||||
if (behaviour === "ready") this.emit("ready");
|
||||
else if (behaviour === "auth-fail")
|
||||
this.emit(
|
||||
"error",
|
||||
new Error("All configured authentication methods failed"),
|
||||
);
|
||||
else if (behaviour === "hang") return;
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
end() {
|
||||
this.ended = true;
|
||||
}
|
||||
|
||||
static nextBehaviour: Array<"ready" | "auth-fail" | "hang"> = [];
|
||||
static instances: FakeSshClient[] = [];
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ssh2 from "ssh2";
|
||||
import { FakeSshClient } from "./pipeline-test-helpers.js";
|
||||
|
||||
vi.mock("ssh2", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ssh2")>();
|
||||
const { FakeSshClient: Fake } = await import("./pipeline-test-helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...(actual as unknown as { default: object }).default,
|
||||
Client: Fake,
|
||||
},
|
||||
Client: Fake,
|
||||
};
|
||||
});
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verifier: () => {},
|
||||
createHostVerifier: vi.fn(),
|
||||
applyCertificateAuth: vi.fn(),
|
||||
forgetCert: vi.fn(),
|
||||
applyAgentAuth: vi.fn(),
|
||||
performPortKnocking: vi.fn(),
|
||||
createJumpHostChain: vi.fn(),
|
||||
createSocks5Connection: vi.fn(),
|
||||
resolveHostById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { logger: log, sshLogger: log, authLogger: log, fileLogger: log };
|
||||
});
|
||||
vi.mock("../../../hosts/host-key-verifier.js", () => ({
|
||||
SSHHostKeyVerifier: {
|
||||
preloadHostData: async () => null,
|
||||
createHostVerifier: mocks.createHostVerifier,
|
||||
},
|
||||
}));
|
||||
vi.mock("@termix/plugin-sdk/ssh-certs", () => ({
|
||||
applyCertificateAuth: mocks.applyCertificateAuth,
|
||||
}));
|
||||
vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({
|
||||
applyAgentAuth: mocks.applyAgentAuth,
|
||||
performPortKnocking: mocks.performPortKnocking,
|
||||
}));
|
||||
vi.mock("../../../hosts/jump-host-chain.js", () => ({
|
||||
createJumpHostChain: mocks.createJumpHostChain,
|
||||
}));
|
||||
vi.mock("../../../utils/socks5-helper.js", () => ({
|
||||
createSocks5Connection: mocks.createSocks5Connection,
|
||||
}));
|
||||
vi.mock("../../../hosts/ssh-dns.js", () => ({
|
||||
resolveSshConnectConfigHost: async (config: unknown) => config,
|
||||
}));
|
||||
vi.mock("../../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: mocks.resolveHostById,
|
||||
}));
|
||||
vi.mock("../../../hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
_key: string,
|
||||
factory: () => Promise<unknown>,
|
||||
fn: (client: unknown) => Promise<unknown>,
|
||||
) => fn(await factory()),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildConnectConfig,
|
||||
getProfileDefaults,
|
||||
} from "../../../hosts/connect/build-connect-config.js";
|
||||
import {
|
||||
connectHost,
|
||||
getConnectionPoolKey,
|
||||
SshConnectError,
|
||||
withHostConnection,
|
||||
} from "../../../hosts/connect/connect-host.js";
|
||||
import {
|
||||
getSshAuthProvider,
|
||||
isQuickConnectAuthType,
|
||||
listCredentialTypes,
|
||||
registerSshAuthProvider,
|
||||
setSshAuthTypeOwnerSource,
|
||||
} from "../../../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
|
||||
import type { SshConnectHost } from "../../../hosts/connect/types.js";
|
||||
|
||||
const plainKey = ssh2.utils.generateKeyPairSync("ed25519").private;
|
||||
const encryptedKey = ssh2.utils.generateKeyPairSync("ed25519", {
|
||||
passphrase: "secret",
|
||||
cipher: "aes256-cbc",
|
||||
}).private;
|
||||
|
||||
function host(overrides: Partial<SshConnectHost> = {}): SshConnectHost {
|
||||
return {
|
||||
id: 7,
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "root",
|
||||
userId: "owner-1",
|
||||
authType: "password",
|
||||
password: "hunter2",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin-style provider that needs a browser sign-in until it has a
|
||||
* certificate, and forgets it when the host rejects it.
|
||||
*/
|
||||
function registerSignInProvider(hasCert: boolean) {
|
||||
return registerSshAuthProvider({
|
||||
type: "signin",
|
||||
pluginId: "fixture-plugin",
|
||||
labelKey: "fixture.label",
|
||||
needsUserInteraction: true,
|
||||
interaction: "signin",
|
||||
prepare: async (config) => {
|
||||
if (!hasCert) {
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "signin",
|
||||
message: "Sign in first",
|
||||
};
|
||||
}
|
||||
config.password = "cert";
|
||||
return { status: "ready" };
|
||||
},
|
||||
onAuthFailed: (_host, env) => {
|
||||
mocks.forgetCert(env.userId, env.hostId);
|
||||
return {
|
||||
status: "interaction-required",
|
||||
interaction: "signin",
|
||||
message: "Sign in again",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function build(
|
||||
target: SshConnectHost,
|
||||
profile: Parameters<typeof buildConnectConfig>[1]["profile"] = "terminal",
|
||||
) {
|
||||
return buildConnectConfig(target, {
|
||||
userId: "user-1",
|
||||
purpose: "test",
|
||||
profile,
|
||||
client: new FakeSshClient() as never,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
FakeSshClient.nextBehaviour = [];
|
||||
FakeSshClient.instances = [];
|
||||
mocks.createHostVerifier.mockResolvedValue(mocks.verifier);
|
||||
mocks.applyAgentAuth.mockImplementation(async (config) => {
|
||||
config.agent = "agent-object";
|
||||
return { socketPath: "/tmp/agent.sock" };
|
||||
});
|
||||
mocks.applyCertificateAuth.mockImplementation(async (config) => {
|
||||
config.authHandler = "cert-handler";
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConnectConfig per auth type", () => {
|
||||
it("password puts the password in the config", async () => {
|
||||
const { config, outcome } = await build(host());
|
||||
expect(outcome).toEqual({ status: "ready" });
|
||||
expect(config).toMatchObject({
|
||||
host: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "root",
|
||||
password: "hunter2",
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 5,
|
||||
readyTimeout: 120000,
|
||||
});
|
||||
expect(config.hostVerifier).toBe(mocks.verifier);
|
||||
});
|
||||
|
||||
it("password leaves the password out when keyboard-interactive is forced", async () => {
|
||||
const { config, outcome } = await build(
|
||||
host({ forceKeyboardInteractive: true }),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(config.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("password without a password is an error", async () => {
|
||||
const { outcome } = await build(host({ password: null }));
|
||||
expect(outcome).toMatchObject({ status: "error", code: "missing-secret" });
|
||||
});
|
||||
|
||||
it("key sets privateKey, passphrase and keeps a password fallback", async () => {
|
||||
const { config, outcome } = await build(
|
||||
host({
|
||||
authType: "key",
|
||||
key: encryptedKey,
|
||||
keyPassword: "secret",
|
||||
password: "fallback",
|
||||
}),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
|
||||
expect(config.passphrase).toBe("secret");
|
||||
expect(config.password).toBe("fallback");
|
||||
expect(mocks.applyCertificateAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("key with a CA certificate grafts the certificate", async () => {
|
||||
const { config } = await build(
|
||||
host({
|
||||
authType: "key",
|
||||
key: plainKey,
|
||||
password: null,
|
||||
certPublicKey: "ssh-ed25519-cert-v01@openssh.com AAAA",
|
||||
}),
|
||||
);
|
||||
expect(mocks.applyCertificateAuth).toHaveBeenCalledOnce();
|
||||
expect(mocks.applyCertificateAuth.mock.calls[0][2]).toMatchObject({
|
||||
certificate: "ssh-ed25519-cert-v01@openssh.com AAAA",
|
||||
});
|
||||
expect(config.authHandler).toBe("cert-handler");
|
||||
});
|
||||
|
||||
it("key with a failing CA certificate falls back to key only", async () => {
|
||||
mocks.applyCertificateAuth.mockRejectedValueOnce(new Error("bad cert"));
|
||||
const { outcome } = await build(
|
||||
host({ authType: "key", key: plainKey, certPublicKey: "junk" }),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
});
|
||||
|
||||
it("an encrypted key without its passphrase asks for one", async () => {
|
||||
const { outcome } = await build(
|
||||
host({ authType: "key", key: encryptedKey }),
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
status: "error",
|
||||
code: "passphrase-required",
|
||||
});
|
||||
});
|
||||
|
||||
it("a broken key is an invalid-key error", async () => {
|
||||
const { outcome } = await build(
|
||||
host({ authType: "key", key: "not a key" }),
|
||||
);
|
||||
expect(outcome).toMatchObject({ status: "error", code: "invalid-key" });
|
||||
});
|
||||
|
||||
it("credential behaves like whichever secret the resolver left", async () => {
|
||||
const keyed = await build(
|
||||
host({ authType: "credential", key: plainKey, password: null }),
|
||||
);
|
||||
expect(Buffer.isBuffer(keyed.config.privateKey)).toBe(true);
|
||||
|
||||
const passworded = await build(host({ authType: "credential" }));
|
||||
expect(passworded.config.password).toBe("hunter2");
|
||||
|
||||
const empty = await build(host({ authType: "credential", password: null }));
|
||||
expect(empty.outcome).toMatchObject({ code: "missing-secret" });
|
||||
});
|
||||
|
||||
it("agent hands the config to the agent helper", async () => {
|
||||
const { config, outcome } = await build(
|
||||
host({
|
||||
authType: "agent",
|
||||
password: null,
|
||||
sshOptions: { agentSocketPath: "/tmp/agent.sock" },
|
||||
}),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(config.agent).toBe("agent-object");
|
||||
expect(mocks.applyAgentAuth).toHaveBeenCalledWith(config, {
|
||||
agentSocketPath: "/tmp/agent.sock",
|
||||
});
|
||||
});
|
||||
|
||||
it("agent errors are reported", async () => {
|
||||
mocks.applyAgentAuth.mockResolvedValueOnce({ error: "no socket" });
|
||||
const { outcome } = await build(host({ authType: "agent" }));
|
||||
expect(outcome).toEqual({
|
||||
status: "error",
|
||||
code: "failed",
|
||||
message: "no socket",
|
||||
});
|
||||
});
|
||||
|
||||
it("none adds no credentials", async () => {
|
||||
const { config, outcome } = await build(
|
||||
host({ authType: "none", password: null }),
|
||||
);
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(config.password).toBeUndefined();
|
||||
expect(config.privateKey).toBeUndefined();
|
||||
expect(config.tryKeyboard).toBe(true);
|
||||
});
|
||||
|
||||
// OPKSSH is a plugin-registered SSH auth provider now: see
|
||||
// plugins/opkssh/tests/backend/provider.test.ts.
|
||||
|
||||
// Step CA is the step-ca plugin's provider: see
|
||||
// plugins/step-ca/tests/backend/step-ca.test.ts.
|
||||
|
||||
it("a stepca host without the step-ca plugin names it", async () => {
|
||||
setSshAuthTypeOwnerSource(() => [
|
||||
{ type: "stepca", pluginId: "step-ca", pluginName: "Step CA" },
|
||||
]);
|
||||
const { outcome } = await build(host({ authType: "stepca" }));
|
||||
expect(outcome).toEqual({
|
||||
status: "error",
|
||||
code: "provider-missing",
|
||||
message: "This host uses stepca, which needs the Step CA plugin",
|
||||
});
|
||||
setSshAuthTypeOwnerSource(() => []);
|
||||
});
|
||||
|
||||
// Vault is the vault plugin's provider: see
|
||||
// plugins/vault/tests/backend/vault.test.ts.
|
||||
|
||||
it("a vault host without the vault plugin names it", async () => {
|
||||
setSshAuthTypeOwnerSource(() => [
|
||||
{ type: "vault", pluginId: "vault", pluginName: "HashiCorp Vault" },
|
||||
]);
|
||||
const { outcome } = await build(host({ authType: "vault" }));
|
||||
expect(outcome).toEqual({
|
||||
status: "error",
|
||||
code: "provider-missing",
|
||||
message: "This host uses vault, which needs the HashiCorp Vault plugin",
|
||||
});
|
||||
setSshAuthTypeOwnerSource(() => []);
|
||||
});
|
||||
|
||||
// Tailscale is a plugin-registered SSH auth provider now: see
|
||||
// plugins/tailscale/tests/backend/ssh-auth-provider.test.ts.
|
||||
|
||||
it("a type nobody provides names the plugin that would", async () => {
|
||||
setSshAuthTypeOwnerSource(() => [
|
||||
{ type: "made-up", pluginId: "made", pluginName: "Made Up" },
|
||||
]);
|
||||
const { outcome } = await build(host({ authType: "made-up" }));
|
||||
expect(outcome).toEqual({
|
||||
status: "error",
|
||||
code: "provider-missing",
|
||||
message: "This host uses made-up, which needs the Made Up plugin",
|
||||
});
|
||||
setSshAuthTypeOwnerSource(() => []);
|
||||
});
|
||||
|
||||
it("jump hops skip keyboard-interactive for auth type none", async () => {
|
||||
const { config } = await build(
|
||||
host({ authType: "none", password: null }),
|
||||
"jump",
|
||||
);
|
||||
expect(config.tryKeyboard).toBe(false);
|
||||
expect(mocks.createHostVerifier).toHaveBeenCalledWith(
|
||||
7,
|
||||
"10.0.0.7",
|
||||
22,
|
||||
null,
|
||||
"user-1",
|
||||
true,
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile defaults", () => {
|
||||
it("keeps each transport's old keepalive numbers", () => {
|
||||
expect(getProfileDefaults("terminal")).toMatchObject({
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 5,
|
||||
});
|
||||
expect(getProfileDefaults("session")).toMatchObject({
|
||||
keepaliveIntervalMs: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
});
|
||||
expect(getProfileDefaults("background")).toMatchObject({
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the background profile when none is given", () => {
|
||||
expect(getProfileDefaults()).toEqual(getProfileDefaults("background"));
|
||||
});
|
||||
|
||||
it("host keepalive settings apply where the transport honoured them", async () => {
|
||||
const withSettings = host({
|
||||
sshOptions: { keepaliveInterval: 10, keepaliveCountMax: 2 },
|
||||
});
|
||||
const terminal = await build(withSettings, "terminal");
|
||||
expect(terminal.config.keepaliveInterval).toBe(10000);
|
||||
const fleet = await build(withSettings, "background");
|
||||
expect(fleet.config.keepaliveInterval).toBe(30000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider registry", () => {
|
||||
it("registers and removes a plugin provider", async () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
const dispose = registerSshAuthProvider({
|
||||
type: "fixture",
|
||||
pluginId: "fixture-plugin",
|
||||
labelKey: "fixture.label",
|
||||
credentialType: true,
|
||||
prepare: async (config) => {
|
||||
config.password = "from-plugin";
|
||||
return { status: "ready" };
|
||||
},
|
||||
});
|
||||
expect(listCredentialTypes()).toContain("fixture");
|
||||
const { config } = await build(host({ authType: "fixture" }));
|
||||
expect(config.password).toBe("from-plugin");
|
||||
|
||||
dispose();
|
||||
expect(getSshAuthProvider("fixture")).toBeUndefined();
|
||||
expect(listCredentialTypes()).not.toContain("fixture");
|
||||
const { outcome } = await build(host({ authType: "fixture" }));
|
||||
expect(outcome).toMatchObject({ code: "provider-missing" });
|
||||
});
|
||||
|
||||
it("offers core types and opted-in plugin types to Quick Connect", () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
for (const type of ["password", "key", "credential", "agent", "none"]) {
|
||||
expect(isQuickConnectAuthType(type)).toBe(true);
|
||||
}
|
||||
const plain = registerSshAuthProvider({
|
||||
type: "needs-saved-host",
|
||||
pluginId: "fixture-plugin",
|
||||
labelKey: "x",
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
const quick = registerSshAuthProvider({
|
||||
type: "works-unsaved",
|
||||
pluginId: "fixture-plugin",
|
||||
labelKey: "x",
|
||||
quickConnect: true,
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
expect(isQuickConnectAuthType("needs-saved-host")).toBe(false);
|
||||
expect(isQuickConnectAuthType("works-unsaved")).toBe(true);
|
||||
expect(isQuickConnectAuthType("missing")).toBe(false);
|
||||
plain();
|
||||
quick();
|
||||
});
|
||||
|
||||
it("refuses to let a second plugin take over a type", () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
expect(() =>
|
||||
registerSshAuthProvider({
|
||||
type: "password",
|
||||
pluginId: "someone-else",
|
||||
labelKey: "x",
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
}),
|
||||
).toThrow(/already provided by core/);
|
||||
});
|
||||
|
||||
it("offers password and key as credential types", () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
expect(listCredentialTypes()).toEqual(
|
||||
expect.arrayContaining(["password", "key"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectHost", () => {
|
||||
it("resolves the host for the acting user and connects", async () => {
|
||||
mocks.resolveHostById.mockResolvedValueOnce(host());
|
||||
const connection = await connectHost(7, {
|
||||
userId: "user-1",
|
||||
purpose: "fleet",
|
||||
});
|
||||
expect(mocks.resolveHostById).toHaveBeenCalledWith(7, "user-1");
|
||||
const client = connection.client as unknown as FakeSshClient;
|
||||
expect(client.connectConfig).toMatchObject({ password: "hunter2" });
|
||||
connection.dispose();
|
||||
expect(client.ended).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a shared-override resolution exactly as the resolver returned it", async () => {
|
||||
mocks.resolveHostById.mockResolvedValueOnce(
|
||||
host({ authType: "key", key: plainKey, password: null, username: "me" }),
|
||||
);
|
||||
const connection = await connectHost(7, {
|
||||
userId: "recipient",
|
||||
purpose: "fleet",
|
||||
});
|
||||
const config = (connection.client as unknown as FakeSshClient)
|
||||
.connectConfig!;
|
||||
expect(config.username).toBe("me");
|
||||
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
|
||||
expect(config.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails for an unknown or forbidden host", async () => {
|
||||
mocks.resolveHostById.mockResolvedValueOnce(null);
|
||||
await expect(
|
||||
connectHost(9, { userId: "user-1", purpose: "fleet" }),
|
||||
).rejects.toThrow("Host not found or access denied");
|
||||
});
|
||||
|
||||
it("throws the auth outcome instead of connecting", async () => {
|
||||
const dispose = registerSignInProvider(false);
|
||||
const attempt = connectHost(host({ authType: "signin" }), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
});
|
||||
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
|
||||
await expect(
|
||||
connectHost(host({ authType: "signin" }), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
}).catch((error) => error.code),
|
||||
).resolves.toBe("signin-required");
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("answers TOTP on key and password jump hops and closes the entire chain", async () => {
|
||||
const { createJumpHostChain } = await vi.importActual<
|
||||
typeof import("../../../hosts/jump-host-chain.js")
|
||||
>("../../../hosts/jump-host-chain.js");
|
||||
mocks.resolveHostById
|
||||
.mockResolvedValueOnce(
|
||||
host({ id: 2, authType: "key", key: plainKey, password: null }),
|
||||
)
|
||||
.mockResolvedValueOnce(host({ id: 3, password: "hop-password" }));
|
||||
FakeSshClient.nextBehaviour = ["hang", "hang"];
|
||||
const prompt = {
|
||||
ask: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("111111")
|
||||
.mockResolvedValueOnce("222222"),
|
||||
};
|
||||
const pending = createJumpHostChain(
|
||||
[{ hostId: 2 }, { hostId: 3 }],
|
||||
"user-1",
|
||||
prompt,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(FakeSshClient.instances[0]?.connectConfig).toBeTruthy(),
|
||||
);
|
||||
const first = FakeSshClient.instances[0];
|
||||
expect(first.connectConfig?.privateKey).toBeTruthy();
|
||||
const firstAnswer = vi.fn();
|
||||
first.emit(
|
||||
"keyboard-interactive",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[{ prompt: "Verification code:", echo: false }],
|
||||
firstAnswer,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(firstAnswer).toHaveBeenCalledWith(["111111"]),
|
||||
);
|
||||
first.emit("ready");
|
||||
await vi.waitFor(() =>
|
||||
expect(FakeSshClient.instances[1]?.connectConfig).toBeTruthy(),
|
||||
);
|
||||
const second = FakeSshClient.instances[1];
|
||||
const secondAnswer = vi.fn();
|
||||
second.emit(
|
||||
"keyboard-interactive",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[
|
||||
{ prompt: "Password:", echo: false },
|
||||
{ prompt: "Verification code:", echo: false },
|
||||
],
|
||||
secondAnswer,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(secondAnswer).toHaveBeenCalledWith(["hop-password", "222222"]),
|
||||
);
|
||||
second.emit("ready");
|
||||
expect(await pending).toBe(second);
|
||||
expect(prompt.ask.mock.calls[0][0].prompt).toContain("Jump host 1/2");
|
||||
expect(prompt.ask.mock.calls[1][0].prompt).toContain("Jump host 2/2");
|
||||
second.emit("close");
|
||||
expect(first.ended).toBe(true);
|
||||
expect(second.ended).toBe(true);
|
||||
});
|
||||
|
||||
it("passes the interactive prompt channel through to jump hosts", async () => {
|
||||
const jumpClient = new FakeSshClient();
|
||||
mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient);
|
||||
const prompt = { ask: vi.fn().mockResolvedValue("123456") };
|
||||
const connection = await connectHost(host({ jumpHosts: [{ hostId: 2 }] }), {
|
||||
userId: "user-1",
|
||||
prompt,
|
||||
});
|
||||
expect(mocks.createJumpHostChain).toHaveBeenCalledWith(
|
||||
[{ hostId: 2 }],
|
||||
"owner-1",
|
||||
prompt,
|
||||
);
|
||||
connection.dispose();
|
||||
});
|
||||
|
||||
it("goes through the jump chain and forwards to the target", async () => {
|
||||
const jumpClient = new FakeSshClient();
|
||||
mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient);
|
||||
const connection = await connectHost(
|
||||
host({ jumpHosts: [{ hostId: 2 }, { hostId: 3 }] }),
|
||||
{ userId: "user-1", purpose: "fleet" },
|
||||
);
|
||||
expect(mocks.createJumpHostChain).toHaveBeenCalledWith(
|
||||
[{ hostId: 2 }, { hostId: 3 }],
|
||||
"owner-1",
|
||||
undefined,
|
||||
);
|
||||
expect(jumpClient.forwardOut).toHaveBeenCalledWith(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
"10.0.0.7",
|
||||
22,
|
||||
expect.any(Function),
|
||||
);
|
||||
const config = (connection.client as unknown as FakeSshClient)
|
||||
.connectConfig!;
|
||||
expect(config.sock).toEqual({ fakeStream: true });
|
||||
connection.dispose();
|
||||
expect(jumpClient.ended).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a SOCKS5 proxy when the host has one", async () => {
|
||||
mocks.createSocks5Connection.mockResolvedValueOnce({ proxied: true });
|
||||
const connection = await connectHost(
|
||||
host({ useSocks5: true, socks5Host: "proxy", socks5Port: 1080 }),
|
||||
{ userId: "user-1", purpose: "fleet" },
|
||||
);
|
||||
expect(mocks.createSocks5Connection).toHaveBeenCalledWith(
|
||||
"10.0.0.7",
|
||||
22,
|
||||
expect.objectContaining({ socks5Host: "proxy", socks5Port: 1080 }),
|
||||
);
|
||||
expect(
|
||||
(connection.client as unknown as FakeSshClient).connectConfig!.sock,
|
||||
).toEqual({ proxied: true });
|
||||
});
|
||||
|
||||
it("connects over a stream it is given and skips the transport", async () => {
|
||||
const connection = await connectHost(
|
||||
host({
|
||||
jumpHosts: [{ hostId: 2 }],
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy",
|
||||
portKnockSequence: [{ port: 7000 }],
|
||||
}),
|
||||
{
|
||||
userId: "user-1",
|
||||
purpose: "tunnel",
|
||||
sock: { throughSource: true } as never,
|
||||
},
|
||||
);
|
||||
const config = (connection.client as unknown as FakeSshClient)
|
||||
.connectConfig!;
|
||||
expect(config.sock).toEqual({ throughSource: true });
|
||||
expect(config.hostVerifier).toBe(mocks.verifier);
|
||||
expect(mocks.createJumpHostChain).not.toHaveBeenCalled();
|
||||
expect(mocks.createSocks5Connection).not.toHaveBeenCalled();
|
||||
expect(mocks.performPortKnocking).not.toHaveBeenCalled();
|
||||
expect(connection.jumpClient).toBeNull();
|
||||
});
|
||||
|
||||
it("knocks before connecting when the host has a sequence", async () => {
|
||||
await connectHost(
|
||||
host({ portKnockSequence: [{ port: 7000 }, { port: 8000 }] }),
|
||||
{ userId: "user-1", purpose: "fleet" },
|
||||
);
|
||||
expect(mocks.performPortKnocking).toHaveBeenCalledWith("10.0.0.7", [
|
||||
{ port: 7000 },
|
||||
{ port: 8000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => {
|
||||
FakeSshClient.nextBehaviour = ["hang"];
|
||||
const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" });
|
||||
await vi.waitFor(() => {
|
||||
const last = FakeSshClient.instances.at(-1)!;
|
||||
expect(last.connectConfig).not.toBeNull();
|
||||
});
|
||||
const client = FakeSshClient.instances.at(-1)!;
|
||||
const finish = vi.fn();
|
||||
client.emit(
|
||||
"keyboard-interactive",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[
|
||||
{ prompt: "Password: ", echo: false },
|
||||
{ prompt: "Something else: ", echo: true },
|
||||
],
|
||||
finish,
|
||||
);
|
||||
expect(finish).toHaveBeenCalledWith(["hunter2", ""]);
|
||||
client.emit("ready");
|
||||
await expect(attempt).resolves.toMatchObject({ client });
|
||||
});
|
||||
|
||||
it("asks through the prompt channel when there is one", async () => {
|
||||
FakeSshClient.nextBehaviour = ["hang"];
|
||||
const ask = vi.fn().mockResolvedValue("123456");
|
||||
const attempt = connectHost(host(), {
|
||||
userId: "user-1",
|
||||
purpose: "file-manager",
|
||||
prompt: { ask },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(),
|
||||
);
|
||||
const client = FakeSshClient.instances.at(-1)!;
|
||||
const finish = vi.fn();
|
||||
client.emit(
|
||||
"keyboard-interactive",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[
|
||||
{ prompt: "Password: ", echo: false },
|
||||
{ prompt: "Verification code: ", echo: true },
|
||||
],
|
||||
finish,
|
||||
);
|
||||
await vi.waitFor(() => expect(finish).toHaveBeenCalled());
|
||||
expect(ask).toHaveBeenCalledWith({
|
||||
kind: "totp",
|
||||
prompt: "Verification code: ",
|
||||
retry: false,
|
||||
});
|
||||
expect(finish).toHaveBeenCalledWith(["hunter2", "123456"]);
|
||||
client.emit("ready");
|
||||
await attempt;
|
||||
});
|
||||
|
||||
it("times out when the server never answers", async () => {
|
||||
FakeSshClient.nextBehaviour = ["hang"];
|
||||
await expect(
|
||||
connectHost(host(), {
|
||||
userId: "user-1",
|
||||
purpose: "fleet",
|
||||
timeoutMs: 20,
|
||||
}),
|
||||
).rejects.toThrow("SSH connection timeout");
|
||||
});
|
||||
|
||||
it("lets the provider react to an auth failure", async () => {
|
||||
FakeSshClient.nextBehaviour = ["auth-fail"];
|
||||
const dispose = registerSignInProvider(true);
|
||||
await expect(
|
||||
connectHost(host({ authType: "signin" }), {
|
||||
userId: "user-1",
|
||||
purpose: "fleet",
|
||||
}),
|
||||
).rejects.toThrow(/All configured authentication methods failed/);
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7),
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("pools under the old key shape", async () => {
|
||||
expect(getConnectionPoolKey("fleet", host())).toBe(
|
||||
"fleet:owner-1:10.0.0.7:22:root",
|
||||
);
|
||||
expect(
|
||||
getConnectionPoolKey(
|
||||
"stats",
|
||||
host({ useSocks5: true, socks5Host: "p", socks5Port: 1 }),
|
||||
),
|
||||
).toBe("stats:owner-1:10.0.0.7:22:root:socks5:p:1");
|
||||
|
||||
const result = await withHostConnection(
|
||||
"fleet:x",
|
||||
host(),
|
||||
{ userId: "user-1", purpose: "fleet" },
|
||||
async (client) => (client as unknown as FakeSshClient).connectConfig,
|
||||
);
|
||||
expect(result).toMatchObject({ password: "hunter2" });
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
pickResolvedUsername,
|
||||
pickResolvedPassword,
|
||||
expandOidcUsername,
|
||||
expandExternalUsername,
|
||||
} from "../../hosts/credential-username.js";
|
||||
|
||||
describe("pickResolvedUsername", () => {
|
||||
@@ -52,14 +52,14 @@ describe("pickResolvedPassword", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandOidcUsername", () => {
|
||||
describe("expandExternalUsername", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns the username unchanged when it has no placeholder", async () => {
|
||||
expect(await expandOidcUsername("alice", "user-1")).toBe("alice");
|
||||
expect(await expandOidcUsername(undefined, "user-1")).toBeUndefined();
|
||||
expect(await expandExternalUsername("alice", "user-1")).toBe("alice");
|
||||
expect(await expandExternalUsername(undefined, "user-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("expands the placeholder with the user's OIDC identifier", async () => {
|
||||
@@ -69,9 +69,13 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
|
||||
expect(await expand("$external.username", "user-1")).toBe("jdoe");
|
||||
expect(
|
||||
await expand("$external.username-$oidc.preferred_username", "user-1"),
|
||||
).toBe("jdoe-jdoe");
|
||||
});
|
||||
|
||||
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
|
||||
@@ -81,7 +85,7 @@ describe("expandOidcUsername", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"$oidc.preferred_username",
|
||||
@@ -95,7 +99,7 @@ describe("expandOidcUsername", () => {
|
||||
},
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"$oidc.preferred_username",
|
||||
@@ -110,12 +114,14 @@ describe("expandOidcUsername", () => {
|
||||
ssoProviderId: 1,
|
||||
}),
|
||||
}),
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
findById: async () => ({ type: "ldap" }),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async () => [
|
||||
{ providerId: "ldap:1", subject: "jdoe" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
|
||||
});
|
||||
@@ -128,12 +134,14 @@ describe("expandOidcUsername", () => {
|
||||
ssoProviderId: 1,
|
||||
}),
|
||||
}),
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
findById: async () => ({ type: "oidc" }),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async () => [
|
||||
{ providerId: "1", subject: "ldap:1:admin" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"ldap:1:admin",
|
||||
@@ -148,12 +156,14 @@ describe("expandOidcUsername", () => {
|
||||
ssoProviderId: 5,
|
||||
}),
|
||||
}),
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
findById: async () => ({ type: "ldap" }),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async () => [
|
||||
{ providerId: "ldap:5", subject: "admin" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
const { expandExternalUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"ldap:1:admin",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
buildCatalog,
|
||||
catalogNamespaces,
|
||||
isDefaultableField,
|
||||
} from "../../../hosts/defaults/catalog.js";
|
||||
import {
|
||||
defaultValuesEqual,
|
||||
normalizeAuthDefault,
|
||||
normalizeSocks5Default,
|
||||
parseDefaultOverrides,
|
||||
splitDefaultKey,
|
||||
stableStringify,
|
||||
} from "../../../../types/host-defaults.js";
|
||||
|
||||
const manifest = {
|
||||
id: "fixture",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
enableKey: "enabled",
|
||||
enableDefault: true,
|
||||
fields: [
|
||||
{ key: "mode", type: "select", default: "a", options: [] },
|
||||
{ key: "token", type: "secret" },
|
||||
{ key: "config", type: "json", secretKeys: ["password"] },
|
||||
{ key: "mac", type: "string", defaultable: false },
|
||||
{
|
||||
key: "snippet",
|
||||
type: "number",
|
||||
defaultLevels: ["user", "folder"],
|
||||
},
|
||||
{ key: "font", type: "number", default: 14, personal: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginManifest;
|
||||
|
||||
describe("the catalog", () => {
|
||||
it("holds core's keys and each defaultable plugin field", () => {
|
||||
const catalog = buildCatalog([manifest]);
|
||||
expect(catalog.has("core.sshPort")).toBe(true);
|
||||
expect(catalog.has("core.auth")).toBe(true);
|
||||
expect(catalog.has("fixture.enabled")).toBe(true);
|
||||
expect(catalog.has("fixture.mode")).toBe(true);
|
||||
expect(catalog.has("fixture.token")).toBe(false);
|
||||
expect(catalog.has("fixture.config")).toBe(false);
|
||||
expect(catalog.has("fixture.mac")).toBe(false);
|
||||
expect(catalog.get("fixture.snippet")?.levels).toEqual(["user", "folder"]);
|
||||
expect(catalog.get("fixture.font")?.personal).toBe(true);
|
||||
expect(catalog.get("fixture.enabled")?.builtin).toBe(true);
|
||||
expect(catalogNamespaces(catalog)).toEqual(["core", "fixture"]);
|
||||
});
|
||||
|
||||
it("reads an unset plugin value as its manifest default", () => {
|
||||
const mode = buildCatalog([manifest]).get("fixture.mode")!;
|
||||
expect(mode.normalize(null)).toBe("a");
|
||||
expect(mode.normalize("")).toBe("a");
|
||||
expect(mode.normalize("b")).toBe("b");
|
||||
});
|
||||
|
||||
it("never lets a secret be a default", () => {
|
||||
expect(isDefaultableField({ key: "s", type: "secret" })).toBe(false);
|
||||
expect(
|
||||
isDefaultableField({ key: "s", type: "secret", defaultable: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("value shapes", () => {
|
||||
it("keeps only the auth fields its type uses", () => {
|
||||
expect(
|
||||
normalizeAuthDefault({
|
||||
authType: "agent",
|
||||
credentialId: 4,
|
||||
agentSocketPath: "/tmp/agent",
|
||||
}),
|
||||
).toEqual({
|
||||
authType: "agent",
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: false,
|
||||
agentSocketPath: "/tmp/agent",
|
||||
agentIdentity: null,
|
||||
});
|
||||
expect(normalizeAuthDefault(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a proxy's settings while it is off, and chain passwords always", () => {
|
||||
expect(
|
||||
normalizeSocks5Default({ useSocks5: false, socks5Host: "x" }).socks5Host,
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeSocks5Default({
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: [{ host: "a", password: "p" }],
|
||||
}).socks5ProxyChain,
|
||||
).toEqual([{ host: "a" }]);
|
||||
});
|
||||
|
||||
it("compares objects regardless of key order", () => {
|
||||
expect(stableStringify({ b: 1, a: 2 })).toBe(
|
||||
stableStringify({ a: 2, b: 1 }),
|
||||
);
|
||||
expect(defaultValuesEqual([{ a: 1 }], [{ a: 1 }])).toBe(true);
|
||||
});
|
||||
|
||||
it("parses overrides and splits keys", () => {
|
||||
expect(parseDefaultOverrides('{"core":["b","a","a"],"x":"no"}')).toEqual({
|
||||
core: ["a", "b"],
|
||||
});
|
||||
expect(parseDefaultOverrides(null)).toBeNull();
|
||||
expect(splitDefaultKey("ssh-terminal.fontSize")).toEqual([
|
||||
"ssh-terminal",
|
||||
"fontSize",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
readCoreDefault,
|
||||
writeCoreDefault,
|
||||
} from "../../../hosts/defaults/core-columns.js";
|
||||
|
||||
describe("core defaults in column form", () => {
|
||||
it("reads the SSH options a row keeps as JSON", () => {
|
||||
const row = {
|
||||
sshOptions: JSON.stringify({ keepaliveInterval: 30 }),
|
||||
};
|
||||
expect(readCoreDefault("keepaliveInterval", row)).toBe(30);
|
||||
expect(readCoreDefault("keepaliveCountMax", row)).toBe(5);
|
||||
expect(readCoreDefault("allowLegacyAlgorithms", row)).toBe(true);
|
||||
});
|
||||
|
||||
it("writes one SSH option and keeps the others", () => {
|
||||
const row = {
|
||||
sshOptions: JSON.stringify({
|
||||
keepaliveInterval: 30,
|
||||
agentForwarding: true,
|
||||
}),
|
||||
};
|
||||
const patch = writeCoreDefault("keepaliveInterval", 90, row);
|
||||
expect(JSON.parse(patch.sshOptions as string)).toEqual({
|
||||
keepaliveInterval: 90,
|
||||
agentForwarding: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves an SSH host's port with its SSH port, but not a plugin protocol's", () => {
|
||||
expect(
|
||||
writeCoreDefault("sshPort", 2222, { connectionType: "ssh" }),
|
||||
).toEqual({ sshPort: 2222, port: 2222 });
|
||||
expect(
|
||||
writeCoreDefault("sshPort", 2222, { connectionType: "rdp" }),
|
||||
).toEqual({ sshPort: 2222 });
|
||||
});
|
||||
|
||||
it("writes a credential login and clears any key the host held", () => {
|
||||
const patch = writeCoreDefault(
|
||||
"auth",
|
||||
{ authType: "credential", credentialId: 7 },
|
||||
{ sshOptions: null, password: "kept" },
|
||||
);
|
||||
expect(patch).toMatchObject({
|
||||
authType: "credential",
|
||||
credentialId: 7,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
});
|
||||
expect(patch).not.toHaveProperty("password");
|
||||
expect(
|
||||
writeCoreDefault("auth", { authType: "agent" }, {}).password,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips a proxy and keeps the host's own chain passwords", () => {
|
||||
const row = {
|
||||
socks5ProxyChain: JSON.stringify([{ host: "a", password: "mine" }]),
|
||||
};
|
||||
const patch = writeCoreDefault(
|
||||
"socks5",
|
||||
{ useSocks5: true, socks5ProxyChain: [{ host: "a" }] },
|
||||
row,
|
||||
);
|
||||
expect(JSON.parse(patch.socks5ProxyChain as string)).toEqual([
|
||||
{ host: "a", password: "mine" },
|
||||
]);
|
||||
expect(readCoreDefault("socks5", { ...row, ...patch })).toMatchObject({
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: [{ host: "a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("stores empty lists as null and booleans as the column expects", () => {
|
||||
expect(writeCoreDefault("jumpHosts", [], {})).toEqual({ jumpHosts: null });
|
||||
expect(writeCoreDefault("forceKeyboardInteractive", true, {})).toEqual({
|
||||
forceKeyboardInteractive: "true",
|
||||
});
|
||||
expect(
|
||||
readCoreDefault("forceKeyboardInteractive", {
|
||||
forceKeyboardInteractive: "false",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The materialize pass against in-memory tables: hosts, plugin host rows and
|
||||
* the defaults levels.
|
||||
*/
|
||||
|
||||
type Row = Record<string, unknown> & { id: number; userId: string };
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
hosts: [] as Array<Record<string, unknown> & { id: number; userId: string }>,
|
||||
pluginRows: [] as Array<{
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}>,
|
||||
defaults: [] as Array<Record<string, unknown>>,
|
||||
folders: [] as Array<{ id: number; userId: string; name: string }>,
|
||||
events: [] as unknown[],
|
||||
notified: [] as unknown[],
|
||||
}));
|
||||
|
||||
const manifest = vi.hoisted(() => ({
|
||||
id: "term",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
enableKey: "enabled",
|
||||
enableDefault: true,
|
||||
fields: [
|
||||
{ key: "fontSize", type: "number", default: 14 },
|
||||
{ key: "mac", type: "string", defaultable: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => [manifest],
|
||||
}));
|
||||
vi.mock("../../../plugins/events.js", () => ({
|
||||
TOPICS: { hostUpdated: "host.updated" },
|
||||
pluginEvents: { emit: (...args: unknown[]) => db.events.push(args) },
|
||||
}));
|
||||
vi.mock("../../../sync/server/feed.js", () => ({ markChanged: () => {} }));
|
||||
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({ resyncHost: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../plugins/settings.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../../plugins/settings.js")>()),
|
||||
notifySettingChange: (...args: unknown[]) => db.notified.push(args),
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listHosts: async (filter: {
|
||||
hostIds?: number[];
|
||||
userIds?: string[];
|
||||
all?: boolean;
|
||||
}) =>
|
||||
db.hosts.filter(
|
||||
(host) =>
|
||||
filter.all ||
|
||||
filter.hostIds?.includes(host.id) ||
|
||||
filter.userIds?.includes(host.userId),
|
||||
),
|
||||
listForUsers: async (userIds: string[]) =>
|
||||
db.defaults.filter(
|
||||
(row) =>
|
||||
row.level === "admin" || userIds.includes(row.userId as string),
|
||||
),
|
||||
listFolders: async (userIds: string[]) =>
|
||||
db.folders.filter((folder) => userIds.includes(folder.userId)),
|
||||
listHostPlacement: async (userIds: string[]) =>
|
||||
db.hosts
|
||||
.filter((host) => userIds.includes(host.userId))
|
||||
.map((host) => ({
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
folder: (host.folder as string | null) ?? null,
|
||||
parentHostId: (host.parentHostId as number | null) ?? null,
|
||||
})),
|
||||
updateHosts: async (patches: Array<{ id: number; values: object }>) => {
|
||||
for (const patch of patches) {
|
||||
const host = db.hosts.find((row) => row.id === patch.id)!;
|
||||
Object.assign(host, patch.values);
|
||||
}
|
||||
},
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
getAllForScopeIds: async (_scope: string, ids: string[]) =>
|
||||
db.pluginRows.filter((row) => ids.includes(row.scopeId)),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
) => {
|
||||
const existing = db.pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
if (existing) existing.value = value;
|
||||
else
|
||||
db.pluginRows.push({
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value,
|
||||
encrypted: false,
|
||||
});
|
||||
},
|
||||
delete: async (
|
||||
pluginId: string,
|
||||
_scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
) => {
|
||||
db.pluginRows = db.pluginRows.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.pluginId === pluginId &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { materializeHosts } =
|
||||
await import("../../../hosts/defaults/materialize.js");
|
||||
|
||||
function host(partial: Partial<Row> & { id: number }): Row {
|
||||
return {
|
||||
userId: "u1",
|
||||
connectionType: "ssh",
|
||||
port: 22,
|
||||
sshPort: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
folder: null,
|
||||
parentHostId: null,
|
||||
statusCheckEnabled: true,
|
||||
defaultOverrides: null,
|
||||
sharedSource: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function setDefault(
|
||||
level: "admin" | "user" | "folder",
|
||||
key: string,
|
||||
value: unknown,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
const [namespace, name] = key.split(".");
|
||||
db.defaults.push({
|
||||
level,
|
||||
namespace,
|
||||
key: name,
|
||||
value: JSON.stringify(value),
|
||||
userId: level === "admin" ? null : "u1",
|
||||
folderId: null,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function pluginValue(hostId: number, key: string): unknown {
|
||||
const row = db.pluginRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row ? JSON.parse(row.value as string) : undefined;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.hosts = [];
|
||||
db.pluginRows = [];
|
||||
db.defaults = [];
|
||||
db.folders = [];
|
||||
db.events = [];
|
||||
db.notified = [];
|
||||
});
|
||||
|
||||
describe("materializeHosts", () => {
|
||||
it("classifies a host from before host defaults: matching values follow them", async () => {
|
||||
db.hosts = [host({ id: 1, sshPort: 22 }), host({ id: 2, sshPort: 2222 })];
|
||||
db.pluginRows.push({
|
||||
pluginId: "term",
|
||||
scope: "host",
|
||||
scopeId: "2",
|
||||
key: "fontSize",
|
||||
value: "18",
|
||||
encrypted: false,
|
||||
});
|
||||
|
||||
const result = await materializeHosts({ all: true });
|
||||
|
||||
expect(result.changedHostIds).toEqual([]);
|
||||
const first = JSON.parse(db.hosts[0].defaultOverrides as string);
|
||||
const second = JSON.parse(db.hosts[1].defaultOverrides as string);
|
||||
expect(first.core).not.toContain("sshPort");
|
||||
expect(second.core).toContain("sshPort");
|
||||
expect(first.term).toEqual([]);
|
||||
expect(second.term).toEqual(["fontSize"]);
|
||||
// Nothing a user sees changed.
|
||||
expect(db.hosts[1].sshPort).toBe(2222);
|
||||
});
|
||||
|
||||
it("writes a new default into every host that follows it, and no other", async () => {
|
||||
db.hosts = [
|
||||
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||
host({
|
||||
id: 2,
|
||||
sshPort: 2200,
|
||||
defaultOverrides: JSON.stringify({ core: ["sshPort"], term: [] }),
|
||||
}),
|
||||
];
|
||||
setDefault("user", "core.sshPort", 2022);
|
||||
setDefault("admin", "term.fontSize", 16);
|
||||
|
||||
const result = await materializeHosts({ userIds: ["u1"] });
|
||||
|
||||
expect(result.changedHostIds).toEqual([1, 2]);
|
||||
expect(db.hosts[0].sshPort).toBe(2022);
|
||||
expect(db.hosts[0].port).toBe(2022);
|
||||
expect(db.hosts[1].sshPort).toBe(2200);
|
||||
expect(pluginValue(1, "fontSize")).toBe(16);
|
||||
expect(pluginValue(2, "fontSize")).toBe(16);
|
||||
expect(db.events).toHaveLength(2);
|
||||
expect(db.notified).toEqual([["term", "fontSize", 16]]);
|
||||
});
|
||||
|
||||
it("removes the row when a key goes back to the manifest default", async () => {
|
||||
db.hosts = [
|
||||
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||
];
|
||||
db.pluginRows.push({
|
||||
pluginId: "term",
|
||||
scope: "host",
|
||||
scopeId: "1",
|
||||
key: "fontSize",
|
||||
value: "16",
|
||||
encrypted: false,
|
||||
});
|
||||
await materializeHosts({ all: true });
|
||||
expect(pluginValue(1, "fontSize")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows the deepest folder, and a sub-host follows its parent's", async () => {
|
||||
db.folders = [
|
||||
{ id: 10, userId: "u1", name: "Prod" },
|
||||
{ id: 11, userId: "u1", name: "Prod / Web" },
|
||||
];
|
||||
const classified = JSON.stringify({ core: [], term: [] });
|
||||
db.hosts = [
|
||||
host({ id: 1, folder: "Prod / Web", defaultOverrides: classified }),
|
||||
host({ id: 2, parentHostId: 1, defaultOverrides: classified }),
|
||||
host({ id: 3, folder: "Prod", defaultOverrides: classified }),
|
||||
];
|
||||
setDefault("folder", "core.sshPort", 2100, { folderId: 10 });
|
||||
setDefault("folder", "core.sshPort", 2200, { folderId: 11 });
|
||||
|
||||
await materializeHosts({ all: true });
|
||||
|
||||
expect(db.hosts.map((row) => row.sshPort)).toEqual([2200, 2200, 2100]);
|
||||
});
|
||||
|
||||
it("never writes a jump chain through the host itself", async () => {
|
||||
db.folders = [{ id: 10, userId: "u1", name: "Lab" }];
|
||||
db.hosts = [
|
||||
host({
|
||||
id: 1,
|
||||
folder: "Lab",
|
||||
defaultOverrides: JSON.stringify({ core: [], term: [] }),
|
||||
}),
|
||||
];
|
||||
setDefault("folder", "core.jumpHosts", [{ hostId: 1 }, { hostId: 9 }], {
|
||||
folderId: 10,
|
||||
});
|
||||
await materializeHosts({ all: true });
|
||||
expect(JSON.parse(db.hosts[0].jumpHosts as string)).toEqual([
|
||||
{ hostId: 9 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("previews without writing", async () => {
|
||||
db.hosts = [
|
||||
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||
];
|
||||
const result = await materializeHosts(
|
||||
{ all: true },
|
||||
{
|
||||
dryRun: true,
|
||||
overlay: {
|
||||
level: "admin",
|
||||
set: new Map([["core.sshPort", 2022]]),
|
||||
unset: new Set(),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.changedHostIds).toEqual([1]);
|
||||
expect(db.hosts[0].sshPort).toBe(22);
|
||||
});
|
||||
|
||||
it("leaves a desktop's copy of a shared host alone", async () => {
|
||||
db.hosts = [host({ id: 1, sharedSource: "{}" })];
|
||||
setDefault("admin", "core.sshPort", 2022);
|
||||
await materializeHosts({ all: true });
|
||||
expect(db.hosts[0].sshPort).toBe(22);
|
||||
expect(db.hosts[0].defaultOverrides).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
import {
|
||||
effectiveFolderPath,
|
||||
folderChainPaths,
|
||||
isOwnValue,
|
||||
resolveKey,
|
||||
withoutSelfJump,
|
||||
type HostLevels,
|
||||
} from "../../../hosts/defaults/resolve.js";
|
||||
import { coreCatalogEntries } from "../../../hosts/defaults/catalog.js";
|
||||
|
||||
const entry = (key: string) =>
|
||||
coreCatalogEntries().find((info) => info.key === key)!;
|
||||
|
||||
function levels(partial: Partial<HostLevels> = {}): HostLevels {
|
||||
return {
|
||||
admin: new Map(),
|
||||
user: new Map(),
|
||||
folders: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("folderChainPaths", () => {
|
||||
it("lists every ancestor, root first", () => {
|
||||
expect(folderChainPaths("A / B / C")).toEqual(["A", "A / B", "A / B / C"]);
|
||||
expect(folderChainPaths(null)).toEqual([]);
|
||||
expect(folderChainPaths("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveFolderPath", () => {
|
||||
it("takes a sub-host's folder from its nearest placed ancestor", () => {
|
||||
const placement = new Map([
|
||||
[1, { folder: "Prod", parentHostId: null }],
|
||||
[2, { folder: null, parentHostId: 1 }],
|
||||
[3, { folder: null, parentHostId: 2 }],
|
||||
]);
|
||||
expect(effectiveFolderPath(3, placement)).toBe("Prod");
|
||||
expect(effectiveFolderPath(1, placement)).toBe("Prod");
|
||||
});
|
||||
|
||||
it("stops on a parent cycle", () => {
|
||||
const placement = new Map([
|
||||
[1, { folder: null, parentHostId: 2 }],
|
||||
[2, { folder: null, parentHostId: 1 }],
|
||||
]);
|
||||
expect(effectiveFolderPath(1, placement)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKey", () => {
|
||||
it("prefers the deepest folder, then the user, then the server, then built-in", () => {
|
||||
const port = entry("sshPort");
|
||||
expect(resolveKey(port, levels())).toEqual({
|
||||
value: 22,
|
||||
source: { level: "builtin" },
|
||||
});
|
||||
expect(
|
||||
resolveKey(port, levels({ admin: new Map([["core.sshPort", 2200]]) }))
|
||||
?.source.level,
|
||||
).toBe("admin");
|
||||
const all = levels({
|
||||
admin: new Map([["core.sshPort", 2200]]),
|
||||
user: new Map([["core.sshPort", 2201]]),
|
||||
folders: [
|
||||
{ folderId: 1, name: "A", values: new Map([["core.sshPort", 2202]]) },
|
||||
{
|
||||
folderId: 2,
|
||||
name: "A / B",
|
||||
values: new Map([["core.sshPort", 2203]]),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(resolveKey(port, all)).toEqual({
|
||||
value: 2203,
|
||||
source: { level: "folder", folderId: 2, folderName: "A / B" },
|
||||
});
|
||||
all.folders[1].values.clear();
|
||||
expect(resolveKey(port, all)?.value).toBe(2202);
|
||||
});
|
||||
|
||||
it("skips a level the key cannot be set at", () => {
|
||||
const jumps = entry("jumpHosts");
|
||||
const result = resolveKey(
|
||||
jumps,
|
||||
levels({ admin: new Map([["core.jumpHosts", [{ hostId: 5 }]]]) }),
|
||||
);
|
||||
expect(result?.source.level).toBe("builtin");
|
||||
expect(result?.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves nothing for a key with no built-in value and no level", () => {
|
||||
expect(resolveKey(entry("username"), levels())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOwnValue", () => {
|
||||
it("compares normalized values", () => {
|
||||
const port = entry("sshPort");
|
||||
const resolved = { value: 22, source: { level: "builtin" as const } };
|
||||
expect(isOwnValue(port, "22", resolved)).toBe(false);
|
||||
expect(isOwnValue(port, null, resolved)).toBe(false);
|
||||
expect(isOwnValue(port, 2222, resolved)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats any value as the host's own when nothing resolves", () => {
|
||||
const username = entry("username");
|
||||
expect(isOwnValue(username, "root", undefined)).toBe(true);
|
||||
expect(isOwnValue(username, "", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats keepalive left unset as the built-in value", () => {
|
||||
const keepalive = entry("keepaliveInterval");
|
||||
expect(
|
||||
isOwnValue(keepalive, undefined, {
|
||||
value: 60,
|
||||
source: { level: "builtin" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutSelfJump", () => {
|
||||
it("drops the host itself from an inherited jump chain", () => {
|
||||
expect(withoutSelfJump([{ hostId: 1 }, { hostId: 2 }], 2)).toEqual([
|
||||
{ hostId: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => [],
|
||||
}));
|
||||
|
||||
const { validateLevelChange } =
|
||||
await import("../../../hosts/defaults/service.js");
|
||||
const { buildCatalog } = await import("../../../hosts/defaults/catalog.js");
|
||||
|
||||
const catalog = buildCatalog([
|
||||
{
|
||||
id: "fx",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [
|
||||
{ key: "size", type: "number", min: 8, max: 36, default: 14 },
|
||||
{ key: "snippet", type: "number", defaultLevels: ["user"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginManifest,
|
||||
]);
|
||||
|
||||
const context = {
|
||||
actorId: "u1",
|
||||
ownerId: "u1",
|
||||
canUseCredential: async (id: number) => id === 7,
|
||||
canUseHost: async (id: number) => id === 3,
|
||||
};
|
||||
|
||||
describe("validateLevelChange", () => {
|
||||
it("accepts known keys and normalizes their values", async () => {
|
||||
const change = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.sshPort": "2022", "fx.size": "16" }, unset: ["fx.x"] },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(change.errors).toEqual({});
|
||||
expect(change.set.get("core.sshPort")).toBe(2022);
|
||||
expect(change.set.get("fx.size")).toBe(16);
|
||||
expect([...change.unset]).toEqual(["fx.x"]);
|
||||
});
|
||||
|
||||
it("refuses unknown keys, out of range values and levels a key cannot use", async () => {
|
||||
const change = await validateLevelChange(
|
||||
{ level: "admin" },
|
||||
{
|
||||
set: {
|
||||
"core.name": "x",
|
||||
"fx.size": 99,
|
||||
"fx.snippet": 1,
|
||||
"core.jumpHosts": [{ hostId: 3 }],
|
||||
},
|
||||
},
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(Object.keys(change.errors).sort()).toEqual([
|
||||
"core.jumpHosts",
|
||||
"core.name",
|
||||
"fx.size",
|
||||
"fx.snippet",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never takes a password or key login, or a credential at the server level", async () => {
|
||||
const password = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.auth": { authType: "password" } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(password.errors["core.auth"]).toMatch(/cannot be a default/);
|
||||
|
||||
const serverCredential = await validateLevelChange(
|
||||
{ level: "admin" },
|
||||
{ set: { "core.auth": { authType: "credential", credentialId: 7 } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(serverCredential.errors["core.auth"]).toMatch(/server default/);
|
||||
|
||||
const ownCredential = await validateLevelChange(
|
||||
{ level: "folder", folderId: 1, userId: "u1" },
|
||||
{ set: { "core.auth": { authType: "credential", credentialId: 7 } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(ownCredential.errors).toEqual({});
|
||||
|
||||
const otherCredential = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.auth": { authType: "credential", credentialId: 8 } } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(otherCredential.errors["core.auth"]).toMatch(/not available/);
|
||||
});
|
||||
|
||||
it("checks every jump host in a chain", async () => {
|
||||
const change = await validateLevelChange(
|
||||
{ level: "user", userId: "u1" },
|
||||
{ set: { "core.jumpHosts": [{ hostId: 3 }, { hostId: 4 }] } },
|
||||
context,
|
||||
catalog,
|
||||
);
|
||||
expect(change.errors["core.jumpHosts"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
defaults: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => [],
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listForUsers: async () => db.defaults,
|
||||
listFolders: async () => [],
|
||||
listHostPlacement: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { applyHostDefaultsToWrite } =
|
||||
await import("../../../hosts/defaults/write-rules.js");
|
||||
const { applyOverrideChange } =
|
||||
await import("../../../hosts/defaults/overrides.js");
|
||||
|
||||
function userDefault(key: string, value: unknown) {
|
||||
db.defaults.push({
|
||||
level: "user",
|
||||
userId: "u1",
|
||||
folderId: null,
|
||||
namespace: "core",
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.defaults = [];
|
||||
});
|
||||
|
||||
describe("applyHostDefaultsToWrite", () => {
|
||||
it("takes the editor's list as the answer and fills what it inherits", async () => {
|
||||
userDefault("sshPort", 2022);
|
||||
const columns: Record<string, unknown> = {
|
||||
sshPort: 9999,
|
||||
port: 9999,
|
||||
connectionType: "ssh",
|
||||
};
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: null,
|
||||
columns,
|
||||
body: { defaultOverrides: { core: [], term: ["fontSize"] } },
|
||||
});
|
||||
expect(columns.sshPort).toBe(2022);
|
||||
expect(columns.port).toBe(2022);
|
||||
expect(overrides.core).toEqual([]);
|
||||
expect(overrides.term).toEqual(["fontSize"]);
|
||||
});
|
||||
|
||||
it("inherits what a create leaves out, and keeps what it sets", async () => {
|
||||
userDefault("sshPort", 2022);
|
||||
userDefault("statusCheckEnabled", false);
|
||||
const columns: Record<string, unknown> = {
|
||||
sshPort: 22,
|
||||
port: 22,
|
||||
statusCheckEnabled: 1,
|
||||
username: "deploy",
|
||||
connectionType: "ssh",
|
||||
};
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: null,
|
||||
columns,
|
||||
body: { ip: "10.0.0.1", port: 22, username: "deploy" },
|
||||
});
|
||||
expect(columns.statusCheckEnabled).toBe(false);
|
||||
// The port was sent and differs from the default, so it is the host's.
|
||||
expect(overrides.core).toContain("sshPort");
|
||||
expect(columns.sshPort).toBe(22);
|
||||
expect(overrides.core).toContain("username");
|
||||
});
|
||||
|
||||
it("marks a changed key on an update without a list, and keeps the rest", async () => {
|
||||
userDefault("sshPort", 2022);
|
||||
const stored = {
|
||||
id: 5,
|
||||
userId: "u1",
|
||||
sshPort: 2022,
|
||||
port: 2022,
|
||||
statusCheckEnabled: true,
|
||||
username: "root",
|
||||
defaultOverrides: JSON.stringify({ core: ["username"] }),
|
||||
};
|
||||
const columns: Record<string, unknown> = {
|
||||
sshPort: 2022,
|
||||
port: 2022,
|
||||
statusCheckEnabled: 0,
|
||||
username: "root",
|
||||
};
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: 5,
|
||||
columns,
|
||||
body: {},
|
||||
stored: stored as never,
|
||||
});
|
||||
expect(overrides.core).toEqual(["statusCheckEnabled", "username"]);
|
||||
});
|
||||
|
||||
it("keeps a locked key's state for a shared editor", async () => {
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: 5,
|
||||
columns: {},
|
||||
body: { defaultOverrides: { core: [] } },
|
||||
stored: {
|
||||
id: 5,
|
||||
userId: "u1",
|
||||
defaultOverrides: JSON.stringify({ core: ["auth"] }),
|
||||
} as never,
|
||||
lockedKeys: ["auth"],
|
||||
});
|
||||
expect(overrides.core).toContain("auth");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOverrideChange", () => {
|
||||
it("adds and removes keys, per namespace", () => {
|
||||
const next = applyOverrideChange(
|
||||
{ core: ["sshPort"], term: ["fontSize", "theme"] },
|
||||
{ own: [["core", "username"]], inherit: [["term", "theme"]] },
|
||||
);
|
||||
expect(next).toEqual({ core: ["sshPort", "username"], term: ["fontSize"] });
|
||||
});
|
||||
|
||||
it("leaves a namespace nobody classified yet to its first pass", () => {
|
||||
expect(applyOverrideChange({ core: [] }, { own: [["term", "x"]] })).toEqual(
|
||||
{ core: [] },
|
||||
);
|
||||
expect(applyOverrideChange(null, { own: [["core", "x"]] })).toEqual({});
|
||||
});
|
||||
|
||||
it("resets everything, known namespaces included", () => {
|
||||
expect(
|
||||
applyOverrideChange({ core: ["a"] }, { inheritAll: true }, [
|
||||
"core",
|
||||
"term",
|
||||
]),
|
||||
).toEqual({ core: [], term: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,46 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSecretSourceRepository: () => ({
|
||||
listVisibleToUser: async () => [],
|
||||
decryptToken: () => "",
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
}));
|
||||
|
||||
const {
|
||||
clearExternalSecretCache,
|
||||
isSecretReference,
|
||||
resolveExternalSecretRefs,
|
||||
resolveSecretReference,
|
||||
} = await import("../../hosts/external-secrets.js");
|
||||
const { setSecretResolverOwnerSource, resetSecretResolverRegistryForTests } =
|
||||
await import("../../hosts/connect/secret-resolver-registry.js");
|
||||
|
||||
const source = {
|
||||
id: "src-1",
|
||||
userId: "alice",
|
||||
name: "1P",
|
||||
kind: "onepassword-connect",
|
||||
baseUrl: "https://connect.internal",
|
||||
token: "enc",
|
||||
shared: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
describe("isSecretReference", () => {
|
||||
it("recognises a scheme:// reference without confusing it with a secret", () => {
|
||||
expect(isSecretReference("op://v/i/f")).toBe(true);
|
||||
expect(isSecretReference(" op://v/i/f")).toBe(true);
|
||||
expect(isSecretReference("vault+https://x")).toBe(true);
|
||||
expect(isSecretReference("hunter2")).toBe(false);
|
||||
expect(isSecretReference(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("external secret references", () => {
|
||||
beforeEach(() => clearExternalSecretCache());
|
||||
|
||||
it("replaces references in the host's secret fields and leaves plain secrets alone", async () => {
|
||||
const resolver = vi.fn(async (_s, ref: string) => `resolved:${ref}`);
|
||||
const resolver = vi.fn(
|
||||
async (_userId: string, ref: string) => `resolved:${ref}`,
|
||||
);
|
||||
const host: Record<string, unknown> = {
|
||||
password: "op://Infra/box/password",
|
||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||
sudoPassword: "op://Infra/box/sudo",
|
||||
username: "op://not-a-secret-field",
|
||||
};
|
||||
await resolveExternalSecretRefs(host, "alice", {
|
||||
resolver,
|
||||
pickSource: async () => source,
|
||||
});
|
||||
await resolveExternalSecretRefs(host, "alice", { resolver });
|
||||
expect(host.password).toBe("resolved:op://Infra/box/password");
|
||||
expect(host.sudoPassword).toBe("resolved:op://Infra/box/sudo");
|
||||
expect(host.key).toBe("-----BEGIN OPENSSH PRIVATE KEY-----");
|
||||
@@ -48,10 +40,10 @@ describe("external secret references", () => {
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("caches a resolved reference per source for a minute", async () => {
|
||||
it("caches a resolved reference per user for a minute", async () => {
|
||||
const resolver = vi.fn(async () => "s3cret");
|
||||
let clock = 1_000_000;
|
||||
const deps = { resolver, pickSource: async () => source, now: () => clock };
|
||||
const deps = { resolver, now: () => clock };
|
||||
await resolveSecretReference("alice", "op://v/i/f", deps);
|
||||
await resolveSecretReference("alice", "op://v/i/f", deps);
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
@@ -60,12 +52,26 @@ describe("external secret references", () => {
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails clearly when the user has no secret source", async () => {
|
||||
it("fails clearly when no plugin resolves the scheme", async () => {
|
||||
await expect(
|
||||
resolveSecretReference("bob", "op://v/i/f", {
|
||||
resolver: async () => "x",
|
||||
pickSource: async () => null,
|
||||
}),
|
||||
).rejects.toThrow(/no secret source/);
|
||||
resolveSecretReference("bob", "op://v/i/f", {}),
|
||||
).rejects.toThrow(/needs the .* plugin|no enabled plugin resolves/);
|
||||
});
|
||||
|
||||
it("names the plugin that would resolve a disabled scheme", async () => {
|
||||
setSecretResolverOwnerSource(() => [
|
||||
{
|
||||
scheme: "op",
|
||||
pluginId: "secret-sources",
|
||||
pluginName: "Secret Sources",
|
||||
},
|
||||
]);
|
||||
try {
|
||||
await expect(
|
||||
resolveSecretReference("bob", "op://v/i/f", {}),
|
||||
).rejects.toThrow("needs the Secret Sources plugin");
|
||||
} finally {
|
||||
resetSecretResolverRegistryForTests();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const setupCACertAuth = vi.fn();
|
||||
const warn = vi.fn();
|
||||
|
||||
vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({
|
||||
setupCACertAuth: (...args: unknown[]) => setupCACertAuth(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
fileLogger: { warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const { applyCACertIfPresent } =
|
||||
await import("../../../hosts/file-manager/ca-cert-auth.js");
|
||||
|
||||
/**
|
||||
* `setupCACertAuth` had no call site in the file manager at all, while its
|
||||
* sibling `setupOPKSSHCertAuth` had two. So a host whose key is paired with a
|
||||
* user-managed CA-signed certificate authenticated in the terminal and failed
|
||||
* over SFTP, and OPKSSH certificates — going through the other helper — worked
|
||||
* in both places.
|
||||
*/
|
||||
describe("applyCACertIfPresent", () => {
|
||||
const client = {} as never;
|
||||
const key = Buffer.from("private-key");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("attaches a certificate the host carries", async () => {
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
await applyCACertIfPresent(
|
||||
config,
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey: "ssh-rsa-cert-v01@openssh.com AAAA" },
|
||||
"root",
|
||||
"passphrase",
|
||||
);
|
||||
|
||||
expect(setupCACertAuth).toHaveBeenCalledWith(
|
||||
config,
|
||||
client,
|
||||
key,
|
||||
"ssh-rsa-cert-v01@openssh.com AAAA",
|
||||
"root",
|
||||
"passphrase",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when there is no certificate", async () => {
|
||||
// Most hosts. Touching the connection here would change key-only auth.
|
||||
for (const certPublicKey of [undefined, null, "", " "]) {
|
||||
await applyCACertIfPresent(
|
||||
{},
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey },
|
||||
"root",
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
expect(setupCACertAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the connection usable when the certificate is unusable", async () => {
|
||||
// The private key on its own may still be accepted — which is what
|
||||
// happened before this was wired up. Failing the connection here would
|
||||
// turn a working setup into a broken one.
|
||||
setupCACertAuth.mockRejectedValueOnce(new Error("bad cert format"));
|
||||
|
||||
await expect(
|
||||
applyCACertIfPresent(
|
||||
{},
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey: "garbage" },
|
||||
"root",
|
||||
undefined,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("CA certificate setup failed"),
|
||||
expect.objectContaining({ operation: "sftp_ca_cert_auth_failed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import type { SSHSession } from "../../../hosts/file-manager/session.js";
|
||||
|
||||
const commandMocks = vi.hoisted(() => ({
|
||||
execBuffer: vi.fn(),
|
||||
execWithSudoBuffer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/file-manager/session.js", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("../../../hosts/file-manager/session.js")
|
||||
>()),
|
||||
execBuffer: commandMocks.execBuffer,
|
||||
execWithSudoBuffer: commandMocks.execWithSudoBuffer,
|
||||
}));
|
||||
|
||||
import { registerFileContentRoutes } from "../../../hosts/file-manager/content-routes.js";
|
||||
|
||||
type RouteHandler = (request: Request, response: Response) => Promise<unknown>;
|
||||
|
||||
function setupReadRoute() {
|
||||
const routes = new Map<string, RouteHandler>();
|
||||
const app = {
|
||||
get: (path: string, handler: RouteHandler) => routes.set(path, handler),
|
||||
post: vi.fn(),
|
||||
} as unknown as Express;
|
||||
const session = {
|
||||
isConnected: true,
|
||||
lastActive: 0,
|
||||
sudoPassword: "sudo-secret",
|
||||
} as SSHSession;
|
||||
|
||||
registerFileContentRoutes(app, {
|
||||
sshSessions: { session: session },
|
||||
verifySessionOwnership: () => true,
|
||||
});
|
||||
|
||||
const handler = routes.get("/ssh/file_manager/ssh/readFile");
|
||||
if (!handler) throw new Error("readFile route was not registered");
|
||||
|
||||
const request = {
|
||||
query: { sessionId: "session", path: "/root/secret.txt" },
|
||||
userId: "user",
|
||||
} as unknown as Request;
|
||||
const response = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
} as unknown as Response;
|
||||
vi.mocked(response.status).mockReturnValue(response);
|
||||
vi.mocked(response.json).mockReturnValue(response);
|
||||
|
||||
return { handler, request, response, session };
|
||||
}
|
||||
|
||||
describe("file manager readFile", () => {
|
||||
beforeEach(() => {
|
||||
commandMocks.execBuffer.mockReset();
|
||||
commandMocks.execWithSudoBuffer.mockReset();
|
||||
});
|
||||
|
||||
it("falls back to sudo for both the size check and content read", async () => {
|
||||
const { handler, request, response, session } = setupReadRoute();
|
||||
commandMocks.execBuffer
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: "Permission denied",
|
||||
code: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: "Permission denied",
|
||||
code: 1,
|
||||
});
|
||||
commandMocks.execWithSudoBuffer
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("6\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("secret"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await handler(request, response);
|
||||
|
||||
expect(commandMocks.execWithSudoBuffer).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
session,
|
||||
"stat -c%s '/root/secret.txt'",
|
||||
"sudo-secret",
|
||||
);
|
||||
expect(commandMocks.execWithSudoBuffer).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
session,
|
||||
"cat '/root/secret.txt'",
|
||||
"sudo-secret",
|
||||
500 * 1024 * 1024,
|
||||
);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
content: "secret",
|
||||
path: "/root/secret.txt",
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(commandMocks.execBuffer).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
session,
|
||||
"cat '/root/secret.txt'",
|
||||
500 * 1024 * 1024,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a file that grows beyond the limit while being read", async () => {
|
||||
const { handler, request, response } = setupReadRoute();
|
||||
commandMocks.execBuffer
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("5\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: "Command output exceeds 524288000 bytes",
|
||||
code: 1,
|
||||
exceededLimit: true,
|
||||
});
|
||||
|
||||
await handler(request, response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
maxSize: 500 * 1024 * 1024,
|
||||
tooLarge: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps readable files on the unprivileged path", async () => {
|
||||
const { handler, request, response } = setupReadRoute();
|
||||
commandMocks.execBuffer
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("5\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("hello"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await handler(request, response);
|
||||
|
||||
expect(commandMocks.execWithSudoBuffer).not.toHaveBeenCalled();
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
content: "hello",
|
||||
path: "/root/secret.txt",
|
||||
encoding: "utf8",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves percent escapes that are literal path characters", async () => {
|
||||
const { handler, request, response, session } = setupReadRoute();
|
||||
request.query.path = "/tmp/100%/literal%2Fname";
|
||||
commandMocks.execBuffer
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("5\n"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: Buffer.from("hello"),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await handler(request, response);
|
||||
|
||||
expect(commandMocks.execBuffer).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
session,
|
||||
"cat '/tmp/100%/literal%2Fname'",
|
||||
500 * 1024 * 1024,
|
||||
);
|
||||
expect(response.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: "/tmp/100%/literal%2Fname" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized sudo-only files before reading their content", async () => {
|
||||
const { handler, request, response } = setupReadRoute();
|
||||
commandMocks.execBuffer.mockResolvedValueOnce({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: "Permission denied",
|
||||
code: 1,
|
||||
});
|
||||
commandMocks.execWithSudoBuffer.mockResolvedValueOnce({
|
||||
stdout: Buffer.from(String(501 * 1024 * 1024)),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
});
|
||||
|
||||
await handler(request, response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tooLarge: true }),
|
||||
);
|
||||
expect(commandMocks.execBuffer).toHaveBeenCalledTimes(1);
|
||||
expect(commandMocks.execWithSudoBuffer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import type { SSHSession } from "../../../hosts/file-manager/session.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSessionSftp: vi.fn(),
|
||||
execChannel: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../hosts/file-manager/session.js", () => ({
|
||||
...mocks,
|
||||
execWithSudo: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: { getInstance: () => ({}) },
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: vi.fn(),
|
||||
getCurrentSettingValue: vi.fn(),
|
||||
}));
|
||||
import { registerFileOperationRoutes } from "../../../hosts/file-manager/operation-routes.js";
|
||||
|
||||
type Handler = (req: Request, res: Response) => Promise<unknown>;
|
||||
function setup(owned = true) {
|
||||
const routes = new Map<string, Handler>();
|
||||
const app = {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
post: (url: string, fn: Handler) => routes.set(url, fn),
|
||||
delete: vi.fn(),
|
||||
} as unknown as Express;
|
||||
registerFileOperationRoutes(app, {
|
||||
sshSessions: { test: { isConnected: true } as SSHSession },
|
||||
verifySessionOwnership: () => owned,
|
||||
});
|
||||
const res = { status: vi.fn(), json: vi.fn() };
|
||||
res.status.mockReturnValue(res);
|
||||
const req = {
|
||||
userId: "owner",
|
||||
body: { sessionId: "test", path: "/tmp", folderName: "parent/child" },
|
||||
} as unknown as Request;
|
||||
return {
|
||||
run: () =>
|
||||
routes.get("/ssh/file_manager/ssh/createFolder")!(
|
||||
req,
|
||||
res as unknown as Response,
|
||||
),
|
||||
res,
|
||||
};
|
||||
}
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
it("creates nested folders over SFTP when the server cannot execute shell commands", async () => {
|
||||
const mkdir = vi.fn((path, _attrs, cb) =>
|
||||
cb(path === "/tmp" ? { code: 4 } : null),
|
||||
);
|
||||
mocks.getSessionSftp.mockResolvedValue({
|
||||
mkdir,
|
||||
stat: (
|
||||
_path: string,
|
||||
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
|
||||
) => cb(null, { isDirectory: () => true }),
|
||||
});
|
||||
const { run, res } = setup();
|
||||
await run();
|
||||
expect(mkdir.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/tmp",
|
||||
"/tmp/parent",
|
||||
"/tmp/parent/child",
|
||||
]);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: "/tmp/parent/child" }),
|
||||
);
|
||||
expect(mocks.execChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
it("reports permission denial instead of success", async () => {
|
||||
const error = Object.assign(new Error("Permission denied"), { code: 3 });
|
||||
mocks.getSessionSftp.mockResolvedValue({
|
||||
mkdir: (
|
||||
_p: string,
|
||||
_a: unknown,
|
||||
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
|
||||
) => cb(error),
|
||||
stat: (
|
||||
_p: string,
|
||||
cb: (error: unknown, stats?: { isDirectory: () => boolean }) => void,
|
||||
) => cb(error),
|
||||
});
|
||||
const { run, res } = setup();
|
||||
await run();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
it("rejects a session belonging to another user before opening SFTP", async () => {
|
||||
const { run, res } = setup(false);
|
||||
await run();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(mocks.getSessionSftp).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDirectProbeCommand,
|
||||
buildDirectRsyncCommand,
|
||||
quoteShell,
|
||||
shouldBenchmarkDirectTransfer,
|
||||
shouldUseDirectTransfer,
|
||||
} from "../../../hosts/file-manager/direct-transfer-routing.js";
|
||||
|
||||
const endpoint = { host: "10.0.0.2", port: 2222, username: "deploy" };
|
||||
|
||||
describe("direct transfer routing", () => {
|
||||
it("keeps small transfers on the relay without benchmarking", () => {
|
||||
expect(shouldBenchmarkDirectTransfer(32 * 1024 * 1024 - 1)).toBe(false);
|
||||
expect(shouldBenchmarkDirectTransfer(32 * 1024 * 1024)).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a meaningful speed advantage", () => {
|
||||
expect(shouldUseDirectTransfer(700, 1000)).toBe(true);
|
||||
expect(shouldUseDirectTransfer(850, 1000)).toBe(false);
|
||||
expect(shouldUseDirectTransfer(0, 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it("probes without accepting passwords or unknown host keys", () => {
|
||||
const command = buildDirectProbeCommand(endpoint);
|
||||
expect(command).toContain("BatchMode=yes");
|
||||
expect(command).toContain("StrictHostKeyChecking=yes");
|
||||
expect(command).toContain("ConnectTimeout=5");
|
||||
expect(command).toContain("-p 2222");
|
||||
});
|
||||
|
||||
it("quotes source and destination paths for rsync", () => {
|
||||
const command = buildDirectRsyncCommand(
|
||||
endpoint,
|
||||
["/srv/a file", "/srv/it's-safe"],
|
||||
"/opt/releases",
|
||||
true,
|
||||
);
|
||||
expect(command).toContain("--partial --append-verify");
|
||||
expect(command).toContain("--protect-args");
|
||||
expect(command).toContain(quoteShell("/srv/a file"));
|
||||
expect(command).toContain(quoteShell("/srv/it's-safe"));
|
||||
expect(command).toContain("deploy@10.0.0.2:/opt/releases/");
|
||||
});
|
||||
|
||||
it("brackets IPv6 destinations", () => {
|
||||
const command = buildDirectProbeCommand({
|
||||
host: "2001:db8::2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
});
|
||||
expect(command).toContain("root@[2001:db8::2]");
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildDeleteCommand } from "../../../hosts/file-manager/operation-commands.js";
|
||||
|
||||
describe("buildDeleteCommand", () => {
|
||||
it("builds a PowerShell 5.1 compatible delete command for Windows files", () => {
|
||||
const command = buildDeleteCommand(
|
||||
"/C:/Users/Administrator/test.txt",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(command.command).toBe(
|
||||
"Remove-Item -LiteralPath 'C:\\Users\\Administrator\\test.txt' -Force -ErrorAction Stop",
|
||||
);
|
||||
expect(command.commandWithSuccess).toBe(
|
||||
`${command.command}; if ($?) { Write-Output "SUCCESS" }`,
|
||||
);
|
||||
expect(command.commandWithSuccess).not.toContain("&&");
|
||||
});
|
||||
|
||||
it("adds recursive deletion for Windows directories", () => {
|
||||
const command = buildDeleteCommand("C:/Temp/Folder", true);
|
||||
|
||||
expect(command.command).toBe(
|
||||
"Remove-Item -LiteralPath 'C:\\Temp\\Folder' -Recurse -Force -ErrorAction Stop",
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes single quotes in Windows literal paths", () => {
|
||||
const command = buildDeleteCommand("/C:/Temp/O'Brien.txt", false);
|
||||
|
||||
expect(command.command).toBe(
|
||||
"Remove-Item -LiteralPath 'C:\\Temp\\O''Brien.txt' -Force -ErrorAction Stop",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps POSIX delete commands using shell success chaining", () => {
|
||||
const command = buildDeleteCommand("/tmp/O'Brien.txt", false);
|
||||
|
||||
expect(command.command).toBe("rm -f '/tmp/O'\"'\"'Brien.txt'");
|
||||
expect(command.commandWithSuccess).toBe(
|
||||
`${command.command} && echo "SUCCESS"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ChannelOpenSerializer,
|
||||
execBuffer,
|
||||
execWithSudoBuffer,
|
||||
type SSHSession,
|
||||
} from "../../../hosts/file-manager/session.js";
|
||||
|
||||
function setupSession() {
|
||||
const stream = new EventEmitter() as EventEmitter & {
|
||||
stderr: EventEmitter;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = vi.fn();
|
||||
stream.write = vi.fn();
|
||||
|
||||
const exec = vi.fn(
|
||||
(
|
||||
_command: string,
|
||||
callback: (error: undefined, channel: typeof stream) => void,
|
||||
) => callback(undefined, stream),
|
||||
);
|
||||
const session = {
|
||||
client: { exec },
|
||||
channelOpener: new ChannelOpenSerializer(),
|
||||
} as unknown as SSHSession;
|
||||
|
||||
return { exec, session, stream };
|
||||
}
|
||||
|
||||
describe("file-manager command helpers", () => {
|
||||
it("writes the sudo password to stdin instead of the command line", async () => {
|
||||
const { exec, session, stream } = setupSession();
|
||||
const resultPromise = execWithSudoBuffer(
|
||||
session,
|
||||
"cat '/root/secret.txt'",
|
||||
"private password",
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(stream.write).toHaveBeenCalled());
|
||||
expect(exec).toHaveBeenCalledWith(
|
||||
"sudo -S -p '' cat '/root/secret.txt' 2>&1",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(exec.mock.calls[0][0]).not.toContain("private password");
|
||||
expect(stream.write).toHaveBeenCalledWith("private password\n");
|
||||
|
||||
stream.emit("close", 0);
|
||||
await expect(resultPromise).resolves.toMatchObject({ code: 0 });
|
||||
});
|
||||
|
||||
it("closes the channel when stdout exceeds the configured limit", async () => {
|
||||
const { session, stream } = setupSession();
|
||||
const resultPromise = execBuffer(session, "cat file", 4);
|
||||
|
||||
await vi.waitFor(() => expect(stream.listenerCount("data")).toBe(1));
|
||||
stream.emit("data", Buffer.from("12345"));
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
code: 1,
|
||||
exceededLimit: true,
|
||||
});
|
||||
expect(stream.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
hashSftpFile,
|
||||
verifySftpFileIntegrity,
|
||||
} from "../../../hosts/file-manager/transfer-integrity.js";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
function fakeSftp(contents: Record<string, Buffer | Buffer[]>): SFTPWrapper {
|
||||
return {
|
||||
createReadStream: vi.fn((path: string) => {
|
||||
const value = contents[path];
|
||||
if (value === undefined) {
|
||||
return new Readable({
|
||||
read() {
|
||||
this.destroy(new Error(`Missing file: ${path}`));
|
||||
},
|
||||
});
|
||||
}
|
||||
return Readable.from(Array.isArray(value) ? value : [value]);
|
||||
}),
|
||||
} as unknown as SFTPWrapper;
|
||||
}
|
||||
|
||||
describe("transfer integrity", () => {
|
||||
it("hashes all chunks in an SFTP file", async () => {
|
||||
const sftp = fakeSftp({
|
||||
"/file": [Buffer.from("hello "), Buffer.from("world")],
|
||||
});
|
||||
|
||||
await expect(hashSftpFile(sftp, "/file")).resolves.toBe(
|
||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts matching source and destination files", async () => {
|
||||
const source = fakeSftp({ "/source": Buffer.from("same bytes") });
|
||||
const dest = fakeSftp({ "/dest": Buffer.from("same bytes") });
|
||||
|
||||
await expect(
|
||||
verifySftpFileIntegrity(source, dest, "/source", "/dest"),
|
||||
).resolves.toMatchObject({ algorithm: "sha256" });
|
||||
});
|
||||
|
||||
it("rejects a corrupted destination", async () => {
|
||||
const source = fakeSftp({ "/source": Buffer.from("expected") });
|
||||
const dest = fakeSftp({ "/dest": Buffer.from("corrupted") });
|
||||
|
||||
await expect(
|
||||
verifySftpFileIntegrity(source, dest, "/source", "/dest"),
|
||||
).rejects.toThrow("SHA-256 verification failed for /source");
|
||||
});
|
||||
|
||||
it("aborts hashing with the caller's cancellation error", async () => {
|
||||
const sftp = fakeSftp({ "/file": Buffer.from("data") });
|
||||
const cancelled = new Error("cancelled by test");
|
||||
|
||||
await expect(
|
||||
hashSftpFile(
|
||||
sftp,
|
||||
"/file",
|
||||
() => true,
|
||||
() => cancelled,
|
||||
),
|
||||
).rejects.toBe(cancelled);
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
estimateIncompressibleSample,
|
||||
resolveArchiveTransferMethod,
|
||||
type TransferScanSummary,
|
||||
} from "../../../hosts/file-manager/transfer-routing.js";
|
||||
|
||||
describe("transfer content sampling", () => {
|
||||
it("recognizes repetitive content as compressible", () => {
|
||||
expect(estimateIncompressibleSample(Buffer.alloc(64 * 1024, 65))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes high-entropy content as incompressible", () => {
|
||||
const sample = Buffer.alloc(64 * 1024);
|
||||
let state = 0x12345678;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
sample[i] = state & 0xff;
|
||||
}
|
||||
expect(estimateIncompressibleSample(sample)).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers sampled content over a misleading extension", () => {
|
||||
const summary: TransferScanSummary = {
|
||||
fileCount: 120,
|
||||
totalBytes: 1024 * 1024 * 1024,
|
||||
largestFileBytes: 32 * 1024 * 1024,
|
||||
incompressibleRatio: 0,
|
||||
sampledIncompressibleRatio: 1,
|
||||
};
|
||||
expect(
|
||||
resolveArchiveTransferMethod("auto", summary, "unix", "unix", true, true),
|
||||
).toBe("item_sftp");
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
clearTransferProfiles,
|
||||
flushTransferProfiles,
|
||||
getRecentDirectRouteDecision,
|
||||
getTransferProfile,
|
||||
initializeTransferProfiles,
|
||||
recordDirectRouteBenchmark,
|
||||
recordDirectRouteOutcome,
|
||||
recordTransferProfile,
|
||||
selectTransferTuning,
|
||||
updateTransferProfile,
|
||||
} from "../../../hosts/file-manager/transfer-tuning.js";
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe("adaptive transfer tuning", () => {
|
||||
beforeEach(clearTransferProfiles);
|
||||
|
||||
it("keeps small files on one conservative lane", () => {
|
||||
expect(selectTransferTuning(8 * MB)).toEqual({
|
||||
parallelSegmentCount: 1,
|
||||
pipelineConcurrency: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses more lanes for large transfers without exceeding segment count", () => {
|
||||
expect(selectTransferTuning(2 * 1024 * MB)).toEqual({
|
||||
parallelSegmentCount: 4,
|
||||
pipelineConcurrency: 32,
|
||||
});
|
||||
expect(selectTransferTuning(300 * MB).parallelSegmentCount).toBe(2);
|
||||
});
|
||||
|
||||
it("honours an explicit lane selection", () => {
|
||||
expect(selectTransferTuning(2 * 1024 * MB, undefined, 3)).toMatchObject({
|
||||
parallelSegmentCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("backs off after failures", () => {
|
||||
const profile = updateTransferProfile(undefined, {
|
||||
bytes: 256 * MB,
|
||||
durationMs: 1000,
|
||||
lanes: 4,
|
||||
pipelineConcurrency: 32,
|
||||
failed: true,
|
||||
now: 1,
|
||||
});
|
||||
expect(profile.preferredLanes).toBe(2);
|
||||
expect(profile.pipelineConcurrency).toBe(16);
|
||||
});
|
||||
|
||||
it("learns and expires host-pair profiles", () => {
|
||||
recordTransferProfile("a->b", {
|
||||
bytes: 256 * MB,
|
||||
durationMs: 2000,
|
||||
lanes: 2,
|
||||
pipelineConcurrency: 32,
|
||||
failed: false,
|
||||
now: 100,
|
||||
});
|
||||
expect(getTransferProfile("a->b", 101)?.samples).toBe(1);
|
||||
expect(getTransferProfile("a->b", 8 * 24 * 60 * 60 * 1000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses recent route benchmarks and cools down failed direct paths", () => {
|
||||
recordDirectRouteBenchmark("a->b", 700, 1000, 100);
|
||||
expect(getRecentDirectRouteDecision("a->b", 1_000, 101)).toEqual({
|
||||
useDirect: true,
|
||||
directMs: 700,
|
||||
relayMs: 1000,
|
||||
});
|
||||
|
||||
recordDirectRouteOutcome("a->b", true, 102, 500);
|
||||
expect(getRecentDirectRouteDecision("a->b", 1_000, 103)?.useDirect).toBe(
|
||||
false,
|
||||
);
|
||||
expect(getRecentDirectRouteDecision("a->b", 1_000, 1_103)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists only anonymous local profiles across restarts", async () => {
|
||||
const previousDataDir = process.env.DATA_DIR;
|
||||
const dataDir = await fs.mkdtemp(path.join(tmpdir(), "termix-transfer-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
const rawKey = "root@10.0.0.1->deploy@10.0.0.2";
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
await initializeTransferProfiles();
|
||||
recordTransferProfile(rawKey, {
|
||||
bytes: 256 * MB,
|
||||
durationMs: 2000,
|
||||
lanes: 2,
|
||||
pipelineConcurrency: 16,
|
||||
failed: false,
|
||||
now,
|
||||
});
|
||||
recordDirectRouteBenchmark(rawKey, 700, 1000, now);
|
||||
await flushTransferProfiles();
|
||||
|
||||
const stored = await fs.readFile(
|
||||
path.join(dataDir, "adaptive-transfer-profiles.json"),
|
||||
"utf8",
|
||||
);
|
||||
expect(stored).not.toContain(rawKey);
|
||||
expect(Object.keys(JSON.parse(stored).profiles)[0]).toMatch(
|
||||
/^[a-f0-9]{64}$/,
|
||||
);
|
||||
|
||||
clearTransferProfiles();
|
||||
await initializeTransferProfiles();
|
||||
expect(getTransferProfile(rawKey, now + 1)).toMatchObject({ samples: 1 });
|
||||
expect(
|
||||
getRecentDirectRouteDecision(rawKey, 1_000, now + 1),
|
||||
).toMatchObject({ useDirect: true });
|
||||
} finally {
|
||||
clearTransferProfiles();
|
||||
if (previousDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = previousDataDir;
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,235 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SFTPWrapper } from "ssh2";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
isSafeTrashSource,
|
||||
listTrash,
|
||||
moveToTrash,
|
||||
permanentlyDeleteTrashItem,
|
||||
restoreTrashItem,
|
||||
} from "../../../hosts/file-manager/trash-service.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
// Windows only allows symlink creation with elevation or Developer Mode, so the
|
||||
// symlink safety test is skipped where the OS refuses to create one at all.
|
||||
const canCreateSymlinks = (() => {
|
||||
const probe = fs.mkdtempSync(path.join(os.tmpdir(), "termix-symlink-probe-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(probe, "target"));
|
||||
fs.symlinkSync(path.join(probe, "target"), path.join(probe, "link"), "dir");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
fs.rmSync(probe, { recursive: true, force: true });
|
||||
}
|
||||
})();
|
||||
|
||||
function localSftp(home: string): SFTPWrapper {
|
||||
const callback = <T>(
|
||||
promise: Promise<T>,
|
||||
done: (error: Error | undefined, value?: T) => void,
|
||||
) =>
|
||||
promise
|
||||
.then((value) => done(undefined, value))
|
||||
.catch((error) => done(error));
|
||||
return {
|
||||
realpath(
|
||||
_target: string,
|
||||
done: (error: Error | undefined, value?: string) => void,
|
||||
) {
|
||||
done(undefined, home);
|
||||
},
|
||||
stat(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: fs.Stats) => void,
|
||||
) {
|
||||
callback(fs.promises.stat(target), done);
|
||||
},
|
||||
lstat(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: fs.Stats) => void,
|
||||
) {
|
||||
callback(fs.promises.lstat(target), done);
|
||||
},
|
||||
readdir(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: unknown[]) => void,
|
||||
) {
|
||||
callback(
|
||||
fs.promises.readdir(target, { withFileTypes: true }).then((entries) =>
|
||||
entries.map((entry) => ({
|
||||
filename: entry.name,
|
||||
longname: entry.name,
|
||||
attrs: {},
|
||||
})),
|
||||
),
|
||||
done,
|
||||
);
|
||||
},
|
||||
readFile(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: Buffer) => void,
|
||||
) {
|
||||
callback(fs.promises.readFile(target), done);
|
||||
},
|
||||
writeFile(target: string, data: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.writeFile(target, data)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
rename(from: string, to: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.rename(from, to)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
unlink(target: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.unlink(target)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
mkdir(target: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.mkdir(target)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
rmdir(target: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.rmdir(target)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
} as unknown as SFTPWrapper;
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const home = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "termix-trash-"),
|
||||
);
|
||||
temporaryDirectories.push(home);
|
||||
return { home, sftp: localSftp(home) };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) =>
|
||||
fs.promises.rm(directory, { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe("file manager trash safety", () => {
|
||||
it("rejects roots and paths inside the trash", () => {
|
||||
expect(isSafeTrashSource("/", "/home/user/.termix-trash")).toBe(false);
|
||||
expect(isSafeTrashSource("C:/", "C:/Users/user/.termix-trash")).toBe(false);
|
||||
expect(
|
||||
isSafeTrashSource(
|
||||
"/home/user/.termix-trash/files/a",
|
||||
"/home/user/.termix-trash",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts ordinary files and directories", () => {
|
||||
expect(
|
||||
isSafeTrashSource("/home/user/report.txt", "/home/user/.termix-trash"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("moves, lists, and restores a file without changing its contents", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "report.txt");
|
||||
await fs.promises.writeFile(original, "important");
|
||||
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
expect(fs.existsSync(original)).toBe(false);
|
||||
expect(await listTrash(sftp, 7)).toEqual([trashed]);
|
||||
|
||||
await restoreTrashItem(sftp, trashed.id);
|
||||
expect(await fs.promises.readFile(original, "utf8")).toBe("important");
|
||||
expect(await listTrash(sftp, 7)).toEqual([]);
|
||||
});
|
||||
|
||||
it("permanently deletes only the stored trash path", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "folder");
|
||||
await fs.promises.mkdir(original);
|
||||
await fs.promises.writeFile(path.join(original, "nested.txt"), "data");
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
|
||||
await permanentlyDeleteTrashItem(sftp, trashed.id);
|
||||
expect(await listTrash(sftp, 7)).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(!canCreateSymlinks)(
|
||||
"does not follow directory symlinks during permanent deletion",
|
||||
async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const target = path.join(home, "target");
|
||||
const link = path.join(home, "link");
|
||||
await fs.promises.mkdir(target);
|
||||
await fs.promises.writeFile(path.join(target, "keep.txt"), "keep");
|
||||
await fs.promises.symlink(target, link, "dir");
|
||||
|
||||
const trashed = await moveToTrash(sftp, link);
|
||||
await permanentlyDeleteTrashItem(sftp, trashed.id);
|
||||
|
||||
expect(
|
||||
await fs.promises.readFile(path.join(target, "keep.txt"), "utf8"),
|
||||
).toBe("keep");
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses tampered metadata instead of deleting an arbitrary path", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "discard.txt");
|
||||
const protectedFile = path.join(home, "keep.txt");
|
||||
await fs.promises.writeFile(original, "discard");
|
||||
await fs.promises.writeFile(protectedFile, "keep");
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
const metadata = path.join(
|
||||
home,
|
||||
".termix-trash",
|
||||
"info",
|
||||
`${trashed.id}.json`,
|
||||
);
|
||||
const data = JSON.parse(await fs.promises.readFile(metadata, "utf8"));
|
||||
data.trashPath = protectedFile;
|
||||
await fs.promises.writeFile(metadata, JSON.stringify(data));
|
||||
|
||||
await expect(permanentlyDeleteTrashItem(sftp, trashed.id)).rejects.toThrow(
|
||||
"Invalid trash metadata",
|
||||
);
|
||||
expect(await fs.promises.readFile(protectedFile, "utf8")).toBe("keep");
|
||||
});
|
||||
|
||||
it("prunes items after the configured retention period", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "old.txt");
|
||||
await fs.promises.writeFile(original, "old");
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
const metadata = path.join(
|
||||
home,
|
||||
".termix-trash",
|
||||
"info",
|
||||
`${trashed.id}.json`,
|
||||
);
|
||||
const data = JSON.parse(await fs.promises.readFile(metadata, "utf8"));
|
||||
data.deletedAt = "2020-01-01T00:00:00.000Z";
|
||||
await fs.promises.writeFile(metadata, JSON.stringify(data));
|
||||
|
||||
expect(await listTrash(sftp, 7)).toEqual([]);
|
||||
expect(
|
||||
fs.existsSync(path.join(home, ".termix-trash", "files", trashed.id)),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import { PassThrough, Writable } from "node:stream";
|
||||
import type { SSHSession } from "../../../hosts/file-manager/session.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getSessionSftp: vi.fn() }));
|
||||
vi.mock("../../../hosts/file-manager/session.js", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("../../../hosts/file-manager/session.js")
|
||||
>()),
|
||||
getSessionSftp: mocks.getSessionSftp,
|
||||
}));
|
||||
|
||||
import { registerFileContentRoutes } from "../../../hosts/file-manager/content-routes.js";
|
||||
|
||||
type RouteHandler = (request: Request, response: Response) => unknown;
|
||||
|
||||
/**
|
||||
* Behaves like ssh2's SFTP WriteStream on current Node: it destroys itself
|
||||
* inside `_final` (autoClose), so a `finish` event is never emitted; only
|
||||
* `close` follows the last acknowledged write.
|
||||
*/
|
||||
class Ssh2LikeWriteStream extends Writable {
|
||||
chunks: Buffer[] = [];
|
||||
constructor() {
|
||||
super({ emitClose: false, autoDestroy: false });
|
||||
}
|
||||
_write(chunk: Buffer, _enc: BufferEncoding, cb: () => void) {
|
||||
this.chunks.push(Buffer.from(chunk));
|
||||
cb();
|
||||
}
|
||||
_final(cb: () => void) {
|
||||
this.destroy();
|
||||
cb();
|
||||
}
|
||||
_destroy(err: Error | null, cb: (err: Error | null) => void) {
|
||||
// The handle close round-trip happens later; 'close' is asynchronous.
|
||||
setTimeout(() => {
|
||||
cb(err);
|
||||
this.emit("close");
|
||||
}, 5);
|
||||
}
|
||||
get body() {
|
||||
return Buffer.concat(this.chunks);
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const routes = new Map<string, RouteHandler>();
|
||||
const app = {
|
||||
get: vi.fn(),
|
||||
post: (path: string, handler: RouteHandler) => routes.set(path, handler),
|
||||
} as unknown as Express;
|
||||
const session = { isConnected: true, lastActive: 0 } as SSHSession;
|
||||
registerFileContentRoutes(app, {
|
||||
sshSessions: { s1: session },
|
||||
verifySessionOwnership: () => true,
|
||||
});
|
||||
const writeStream = new Ssh2LikeWriteStream();
|
||||
const unlink = vi.fn((_p: string, cb: (e: null) => void) => cb(null));
|
||||
mocks.getSessionSftp.mockResolvedValue({
|
||||
createWriteStream: vi.fn(() => writeStream),
|
||||
unlink,
|
||||
});
|
||||
const response = { status: vi.fn(), json: vi.fn() } as unknown as Response;
|
||||
vi.mocked(response.status).mockReturnValue(response);
|
||||
const responded = new Promise<unknown>((resolve) => {
|
||||
vi.mocked(response.json).mockImplementation((payload: unknown) => {
|
||||
resolve(payload);
|
||||
return response;
|
||||
});
|
||||
});
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("route never responded")), 2000),
|
||||
);
|
||||
return {
|
||||
routes,
|
||||
writeStream,
|
||||
unlink,
|
||||
response,
|
||||
responded: Promise.race([responded, timeout]),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => mocks.getSessionSftp.mockReset());
|
||||
|
||||
describe("uploadFileStream completion", () => {
|
||||
function multipartRequest(body: Buffer, fields: Record<string, string>) {
|
||||
const boundary = "----termixTestBoundary";
|
||||
let preamble = "";
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`;
|
||||
}
|
||||
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="payload.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`;
|
||||
const req = new PassThrough() as PassThrough & Request;
|
||||
Object.assign(req, {
|
||||
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
||||
userId: "u1",
|
||||
complete: false,
|
||||
});
|
||||
const send = async () => {
|
||||
req.write(preamble);
|
||||
req.write(body);
|
||||
req.write(`\r\n--${boundary}--\r\n`);
|
||||
(req as { complete: boolean }).complete = true;
|
||||
req.end();
|
||||
};
|
||||
return { req, send };
|
||||
}
|
||||
|
||||
it("answers once the SFTP stream closes, even though ssh2 never emits 'finish'", async () => {
|
||||
const { routes, writeStream, response, responded } = setup();
|
||||
const payload = Buffer.from("hello over sftp");
|
||||
const { req, send } = multipartRequest(payload, {
|
||||
sessionId: "s1",
|
||||
path: "/home/ubuntu",
|
||||
});
|
||||
routes.get("/ssh/file_manager/ssh/uploadFileStream")!(req, response);
|
||||
await send();
|
||||
const answer = (await responded) as { message: string; path: string };
|
||||
expect(answer.message).toBe("File uploaded successfully");
|
||||
expect(answer.path).toBe("/home/ubuntu/payload.bin");
|
||||
expect(writeStream.body.equals(payload)).toBe(true);
|
||||
expect(vi.mocked(response.status)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not report success when the client aborts before the body ended", async () => {
|
||||
const { routes, writeStream, unlink, response } = setup();
|
||||
const { req } = multipartRequest(Buffer.alloc(0), {
|
||||
sessionId: "s1",
|
||||
path: "/home/ubuntu",
|
||||
});
|
||||
routes.get("/ssh/file_manager/ssh/uploadFileStream")!(req, response);
|
||||
// Headers + the beginning of the file part arrive, then the socket dies.
|
||||
req.write(
|
||||
`------termixTestBoundary\r\nContent-Disposition: form-data; name="sessionId"\r\n\r\ns1\r\n` +
|
||||
`------termixTestBoundary\r\nContent-Disposition: form-data; name="path"\r\n\r\n/home/ubuntu\r\n` +
|
||||
`------termixTestBoundary\r\nContent-Disposition: form-data; name="file"; filename="payload.bin"\r\n\r\npartial`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
req.emit("aborted");
|
||||
// The partial is removed and its stream closes; that close is not a success.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(writeStream.destroyed).toBe(true);
|
||||
expect(unlink).toHaveBeenCalledWith(
|
||||
"/home/ubuntu/payload.bin",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(vi.mocked(response.json)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadFileChunk completion", () => {
|
||||
function chunkRequest(query: Record<string, string>) {
|
||||
const req = new PassThrough() as PassThrough & Request;
|
||||
Object.assign(req, { query, headers: {}, userId: "u1" });
|
||||
return req;
|
||||
}
|
||||
const query = {
|
||||
sessionId: "s1",
|
||||
path: "/home/ubuntu",
|
||||
fileName: "big.bin",
|
||||
offset: "0",
|
||||
totalSize: "6",
|
||||
};
|
||||
|
||||
it("streams the raw body and answers on the ssh2 'close' event", async () => {
|
||||
const { routes, writeStream, response, responded } = setup();
|
||||
const req = chunkRequest(query);
|
||||
routes.get("/ssh/file_manager/ssh/uploadFileChunk")!(req, response);
|
||||
await new Promise((r) => setTimeout(r, 10)); // let getSessionSftp resolve
|
||||
req.write("abc");
|
||||
req.end("def");
|
||||
const answer = (await responded) as {
|
||||
bytesWritten: number;
|
||||
nextOffset: number;
|
||||
complete: boolean;
|
||||
};
|
||||
expect(answer).toMatchObject({
|
||||
bytesWritten: 6,
|
||||
nextOffset: 6,
|
||||
complete: true,
|
||||
});
|
||||
expect(writeStream.body.toString()).toBe("abcdef");
|
||||
});
|
||||
|
||||
it("still writes the chunk when middleware already drained the body into req.body", async () => {
|
||||
const { routes, writeStream, response, responded } = setup();
|
||||
const req = chunkRequest(query);
|
||||
// What express.raw() leaves behind: a Buffer body and an ended stream.
|
||||
Object.assign(req, { body: Buffer.from("abcdef") });
|
||||
req.end();
|
||||
routes.get("/ssh/file_manager/ssh/uploadFileChunk")!(req, response);
|
||||
const answer = (await responded) as { bytesWritten: number };
|
||||
expect(answer.bytesWritten).toBe(6);
|
||||
expect(writeStream.body.toString()).toBe("abcdef");
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isExecutableFile,
|
||||
modeToPermissions,
|
||||
formatMtime,
|
||||
getMimeType,
|
||||
detectBinary,
|
||||
parseLsDateToTimestamp,
|
||||
} from "../../../hosts/file-manager/utils.js";
|
||||
|
||||
describe("isExecutableFile", () => {
|
||||
it("flags scripts with execute permission", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "deploy.sh")).toBe(true);
|
||||
expect(isExecutableFile("-rwxr-xr-x", "run.py")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags known executable extensions with execute permission", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "tool.bin")).toBe(true);
|
||||
expect(isExecutableFile("-rwxr-xr-x", "app.exe")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags extensionless files with execute permission", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "myprogram")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag files without execute permission", () => {
|
||||
expect(isExecutableFile("-rw-r--r--", "deploy.sh")).toBe(false);
|
||||
expect(isExecutableFile("-rw-r--r--", "myprogram")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag non-script data files even when executable", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "notes.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modeToPermissions", () => {
|
||||
it("renders a regular file with rwxr-xr-x", () => {
|
||||
expect(modeToPermissions(0o100755)).toBe("-rwxr-xr-x");
|
||||
});
|
||||
|
||||
it("renders a directory prefix", () => {
|
||||
expect(modeToPermissions(0o040755)).toBe("drwxr-xr-x");
|
||||
});
|
||||
|
||||
it("renders a symlink prefix", () => {
|
||||
expect(modeToPermissions(0o120777)).toBe("lrwxrwxrwx");
|
||||
});
|
||||
|
||||
it("renders a read-only file", () => {
|
||||
expect(modeToPermissions(0o100444)).toBe("-r--r--r--");
|
||||
});
|
||||
|
||||
it("renders no permissions", () => {
|
||||
expect(modeToPermissions(0o100000)).toBe("----------");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMtime", () => {
|
||||
it("uses HH:MM format for recent timestamps", () => {
|
||||
// Within the last 6 months relative to now.
|
||||
const recent = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 5;
|
||||
const result = formatMtime(recent);
|
||||
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("uses the year for old timestamps", () => {
|
||||
// ~2 years ago is comfortably outside the 6-month window.
|
||||
const old = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 365 * 2;
|
||||
const result = formatMtime(old);
|
||||
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} +\d{4}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMimeType", () => {
|
||||
it("maps known extensions", () => {
|
||||
expect(getMimeType("readme.txt")).toBe("text/plain");
|
||||
expect(getMimeType("data.json")).toBe("application/json");
|
||||
expect(getMimeType("photo.JPEG")).toBe("image/jpeg");
|
||||
expect(getMimeType("archive.zip")).toBe("application/zip");
|
||||
});
|
||||
|
||||
it("falls back to octet-stream for unknown or missing extensions", () => {
|
||||
expect(getMimeType("mystery.xyz")).toBe("application/octet-stream");
|
||||
expect(getMimeType("noextension")).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLsDateToTimestamp", () => {
|
||||
it("parses a year-format date string", () => {
|
||||
const ts = parseLsDateToTimestamp("Dec 12 2025");
|
||||
const d = new Date(ts * 1000);
|
||||
expect(d.getFullYear()).toBe(2025);
|
||||
expect(d.getMonth()).toBe(11);
|
||||
expect(d.getDate()).toBe(12);
|
||||
});
|
||||
|
||||
it("parses a time-format date string for a recent file", () => {
|
||||
const now = new Date();
|
||||
const month = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
][now.getMonth()];
|
||||
const day = now.getDate();
|
||||
const ts = parseLsDateToTimestamp(`${month} ${day} 10:30`);
|
||||
const d = new Date(ts * 1000);
|
||||
expect(d.getHours()).toBe(10);
|
||||
expect(d.getMinutes()).toBe(30);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty or invalid string", () => {
|
||||
expect(parseLsDateToTimestamp("")).toBe(0);
|
||||
expect(parseLsDateToTimestamp("Xyz 5 12:00")).toBe(0);
|
||||
});
|
||||
|
||||
it("produces ascending order for older vs newer dates", () => {
|
||||
const older = parseLsDateToTimestamp("Jan 1 2020");
|
||||
const newer = parseLsDateToTimestamp("Dec 31 2024");
|
||||
expect(older).toBeLessThan(newer);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectBinary", () => {
|
||||
it("returns false for empty buffers", () => {
|
||||
expect(detectBinary(Buffer.from([]))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for plain UTF-8 text", () => {
|
||||
expect(detectBinary(Buffer.from("hello world\nsecond line\t tab"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true when null bytes are present", () => {
|
||||
expect(detectBinary(Buffer.from([0x48, 0x00, 0x49, 0x00]))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows common whitespace control characters", () => {
|
||||
expect(detectBinary(Buffer.from("line1\r\nline2\tend"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("file manager Vault authentication", () => {
|
||||
it("configures the cached Vault certificate before connecting", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../../hosts/file-manager/index.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const vaultBranch = source.indexOf(
|
||||
'resolvedCredentials.authType === "vault"',
|
||||
);
|
||||
const agentBranch = source.indexOf(
|
||||
'resolvedCredentials.authType === "agent"',
|
||||
vaultBranch,
|
||||
);
|
||||
const body = source.slice(vaultBranch, agentBranch);
|
||||
|
||||
expect(vaultBranch).toBeGreaterThan(-1);
|
||||
expect(agentBranch).toBeGreaterThan(vaultBranch);
|
||||
expect(body).toContain("setupVaultSshSignerAuth");
|
||||
expect(body).toContain("requiresVaultAuth: true");
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withDriveSettings } from "../../../hosts/guacamole/drive-settings.js";
|
||||
|
||||
describe("withDriveSettings", () => {
|
||||
it("gives each user a folder under GUACD_DRIVE_PATH and creates it", () => {
|
||||
expect(
|
||||
withDriveSettings({ "enable-drive": true }, "user-1", {
|
||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive/",
|
||||
}),
|
||||
).toEqual({
|
||||
"enable-drive": true,
|
||||
"drive-path": "/termix-data/rdp-drive/user-1",
|
||||
"create-drive-path": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to /drive when the environment says nothing", () => {
|
||||
expect(
|
||||
withDriveSettings({ "enable-drive": true }, "user-1", {}),
|
||||
).toMatchObject({ "drive-path": "/drive/user-1" });
|
||||
});
|
||||
|
||||
it("leaves a host-chosen drive-path alone", () => {
|
||||
const config = { "enable-drive": true, "drive-path": "/mnt/share" };
|
||||
expect(
|
||||
withDriveSettings(config, "user-1", { GUACD_DRIVE_PATH: "/x" }),
|
||||
).toBe(config);
|
||||
});
|
||||
|
||||
it("does nothing when the drive is not enabled", () => {
|
||||
const config = { "enable-drive": false };
|
||||
expect(withDriveSettings(config, "user-1")).toBe(config);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveJumpTunnelEndpoint } from "../../../hosts/guacamole/jump-tunnel-endpoint.js";
|
||||
|
||||
describe("resolveJumpTunnelEndpoint", () => {
|
||||
it("keeps an in-process guacd tunnel on loopback", () => {
|
||||
expect(resolveJumpTunnelEndpoint("localhost")).toEqual({
|
||||
bindHost: "127.0.0.1",
|
||||
advertisedHost: "127.0.0.1",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the tunnel to a separate guacd container", () => {
|
||||
expect(resolveJumpTunnelEndpoint("guacd")).toEqual({
|
||||
bindHost: "0.0.0.0",
|
||||
advertisedHost: "termix",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports a custom backend hostname for external guacd", () => {
|
||||
expect(
|
||||
resolveJumpTunnelEndpoint("guacd.example", "termix-backend"),
|
||||
).toEqual({
|
||||
bindHost: "0.0.0.0",
|
||||
advertisedHost: "termix-backend",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import net from "net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createMacosVncCompatibilityProxy } from "../../../hosts/guacamole/macos-vnc-proxy.js";
|
||||
|
||||
const closers: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const close of closers.splice(0)) close();
|
||||
});
|
||||
|
||||
async function listen(server: net.Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
resolve((server.address() as net.AddressInfo).port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function read(socket: net.Socket, length: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let received = Buffer.alloc(0);
|
||||
const onData = (chunk: Buffer) => {
|
||||
received = Buffer.concat([received, chunk]);
|
||||
if (received.length < length) return;
|
||||
socket.off("data", onData);
|
||||
resolve(received);
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("createMacosVncCompatibilityProxy", () => {
|
||||
it("normalizes Apple's private RFB banner and forces classic VNC auth", async () => {
|
||||
let clientBanner = "";
|
||||
let selectedSecurityType = 0;
|
||||
const target = net.createServer((socket) => {
|
||||
socket.write("RFB 003.");
|
||||
socket.write("889\n");
|
||||
socket.once("data", (data) => {
|
||||
clientBanner = data.toString("ascii");
|
||||
socket.write(Buffer.from([5, 30, 33]));
|
||||
socket.write(Buffer.from([36, 2, 35]));
|
||||
socket.once("data", (selection) => {
|
||||
selectedSecurityType = selection[0];
|
||||
socket.write("desktop-data");
|
||||
});
|
||||
});
|
||||
});
|
||||
const targetPort = await listen(target);
|
||||
closers.push(() => target.close());
|
||||
|
||||
const proxy = await createMacosVncCompatibilityProxy({
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort,
|
||||
bindHost: "127.0.0.1",
|
||||
});
|
||||
closers.push(proxy.close);
|
||||
|
||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
||||
closers.push(() => client.destroy());
|
||||
expect((await read(client, 12)).subarray(0, 12).toString("ascii")).toBe(
|
||||
"RFB 003.008\n",
|
||||
);
|
||||
client.write("RFB 003.008\n");
|
||||
expect([...(await read(client, 2))]).toEqual([1, 2]);
|
||||
client.write(Buffer.from([2]));
|
||||
expect((await read(client, 12)).toString("ascii")).toBe("desktop-data");
|
||||
expect(clientBanner).toBe("RFB 003.008\n");
|
||||
expect(selectedSecurityType).toBe(2);
|
||||
});
|
||||
|
||||
it("passes standard RFB negotiation through unchanged", async () => {
|
||||
const target = net.createServer((socket) => {
|
||||
socket.write("RFB 003.008\n");
|
||||
socket.once("data", () => socket.write(Buffer.from([2, 30, 2])));
|
||||
});
|
||||
const targetPort = await listen(target);
|
||||
closers.push(() => target.close());
|
||||
const proxy = await createMacosVncCompatibilityProxy({
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort,
|
||||
bindHost: "127.0.0.1",
|
||||
});
|
||||
closers.push(proxy.close);
|
||||
|
||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
||||
closers.push(() => client.destroy());
|
||||
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
|
||||
client.write("RFB 003.008\n");
|
||||
expect([...(await read(client, 3))]).toEqual([2, 30, 2]);
|
||||
});
|
||||
|
||||
it("passes Apple's security types through when VNC auth is unavailable", async () => {
|
||||
const target = net.createServer((socket) => {
|
||||
socket.write("RFB 003.889\n");
|
||||
socket.once("data", () => socket.write(Buffer.from([2, 30, 33])));
|
||||
});
|
||||
const targetPort = await listen(target);
|
||||
closers.push(() => target.close());
|
||||
const proxy = await createMacosVncCompatibilityProxy({
|
||||
targetHost: "127.0.0.1",
|
||||
targetPort,
|
||||
bindHost: "127.0.0.1",
|
||||
});
|
||||
closers.push(proxy.close);
|
||||
|
||||
const client = net.createConnection(proxy.port, "127.0.0.1");
|
||||
closers.push(() => client.destroy());
|
||||
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
|
||||
client.write("RFB 003.008\n");
|
||||
expect([...(await read(client, 3))]).toEqual([2, 30, 33]);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRdpSettings,
|
||||
resolveRdpAuthTypeForConnect,
|
||||
resolveRdpDomain,
|
||||
} from "../../../hosts/guacamole/rdp-settings.js";
|
||||
|
||||
describe("buildRdpSettings", () => {
|
||||
it("keeps saved RDP settings authoritative over stale advanced config", () => {
|
||||
expect(
|
||||
buildRdpSettings({
|
||||
port: 3390,
|
||||
domain: "EXAMPLE",
|
||||
security: "nla",
|
||||
ignoreCert: true,
|
||||
guacConfig: {
|
||||
port: 3389,
|
||||
domain: "OLD",
|
||||
security: "rdp",
|
||||
"ignore-cert": false,
|
||||
"color-depth": 24,
|
||||
},
|
||||
guacdOverrides: { guacdHost: "guacd.internal" },
|
||||
}),
|
||||
).toEqual({
|
||||
port: 3390,
|
||||
domain: "EXAMPLE",
|
||||
security: "nla",
|
||||
"ignore-cert": true,
|
||||
"color-depth": 24,
|
||||
guacdHost: "guacd.internal",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an advanced security value when no saved value exists", () => {
|
||||
expect(
|
||||
buildRdpSettings({
|
||||
port: 3389,
|
||||
ignoreCert: false,
|
||||
guacConfig: { security: "tls" },
|
||||
guacdOverrides: {},
|
||||
}).security,
|
||||
).toBe("tls");
|
||||
});
|
||||
|
||||
it("uses the prompted domain for prompt-on-connect authentication", () => {
|
||||
expect(resolveRdpDomain("none", "EXAMPLE", "OLD")).toBe("EXAMPLE");
|
||||
expect(resolveRdpDomain("none", "", "OLD")).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the stored domain for saved authentication", () => {
|
||||
expect(resolveRdpDomain("direct", "EXAMPLE", "SAVED")).toBe("SAVED");
|
||||
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRdpAuthTypeForConnect", () => {
|
||||
it("keeps prompt-on-connect authentication for a secretless recipient", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "none",
|
||||
sharedResolution: { source: "secretless" },
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("uses a recipient override instead of prompting", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "none",
|
||||
sharedResolution: { source: "personal-override" },
|
||||
}),
|
||||
).toBe("credential");
|
||||
});
|
||||
|
||||
it("uses authentication shared by the owner", () => {
|
||||
expect(
|
||||
resolveRdpAuthTypeForConnect({
|
||||
storedAuthType: "credential",
|
||||
sharedResolution: { source: "owner-shared", authType: "credential" },
|
||||
}),
|
||||
).toBe("credential");
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withRecordingSettings } from "../../../hosts/guacamole/recording-settings.js";
|
||||
|
||||
const PATH = "/app/data/session_recordings/guacamole";
|
||||
const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac";
|
||||
|
||||
describe("withRecordingSettings", () => {
|
||||
it("takes ownership of the location and filename", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{
|
||||
"recording-path": "/var/lib/termix/recordings",
|
||||
"recording-name": "${GUAC_USERNAME}-${GUAC_DATE}",
|
||||
"create-recording-path": false,
|
||||
},
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-path": PATH,
|
||||
"recording-name": NAME,
|
||||
"create-recording-path": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the content flags when the host has no opinion", () => {
|
||||
expect(withRecordingSettings({}, PATH, NAME)).toMatchObject({
|
||||
"recording-exclude-output": false,
|
||||
"recording-include-keys": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the host's content flags, including the falsy ones", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{
|
||||
"recording-exclude-output": true,
|
||||
"recording-include-keys": false,
|
||||
},
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-exclude-output": true,
|
||||
"recording-include-keys": false,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unrelated settings alone", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{ "recording-exclude-mouse": true, width: "1920" },
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-exclude-mouse": true,
|
||||
width: "1920",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the settings it was given", () => {
|
||||
const original = { "recording-path": "/tmp/mine" };
|
||||
withRecordingSettings(original, PATH, NAME);
|
||||
|
||||
expect(original).toEqual({ "recording-path": "/tmp/mine" });
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
it("returns the minted token without waiting for a connection that needs that token", async () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../../hosts/guacamole/routes.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
"routes.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const route = ast.statements.find(
|
||||
(s) =>
|
||||
ts.isExpressionStatement(s) &&
|
||||
ts.isCallExpression(s.expression) &&
|
||||
s.expression.arguments[0]?.getText(ast) === '"/connect-host/:hostId"',
|
||||
) as ts.ExpressionStatement;
|
||||
const callback = (route.expression as ts.CallExpression)
|
||||
.arguments[1] as ts.ArrowFunction;
|
||||
const block = (callback.body as ts.Block).statements[0] as ts.TryStatement;
|
||||
const index = block.tryBlock.statements
|
||||
.map((s, i) => (ts.isSwitchStatement(s) ? i : -1))
|
||||
.filter((i) => i >= 0)
|
||||
.at(-1)!;
|
||||
expect(index).toBeGreaterThan(-1);
|
||||
const tail = block.tryBlock.statements
|
||||
.slice(index + 1)
|
||||
.map((s) => s.getText(ast))
|
||||
.join("\n");
|
||||
const json = vi.fn();
|
||||
const wait = vi.fn(() => {
|
||||
throw new Error("Client has not received the token yet");
|
||||
});
|
||||
await runInNewContext(
|
||||
ts.transpileModule(`(async () => { ${tail} })()`, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText,
|
||||
{
|
||||
waitForGuacdOpen: wait,
|
||||
token: "minted-token",
|
||||
termixConnectId: "correlation-id",
|
||||
req: {},
|
||||
getRequestMeta: () => ({}),
|
||||
logAudit: async () => {},
|
||||
getAuditUsername: async () => "owner",
|
||||
userId: "owner",
|
||||
connectionType: "rdp",
|
||||
hostId: 7,
|
||||
hostname: "host",
|
||||
port: 3389,
|
||||
res: { json },
|
||||
},
|
||||
);
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
token: "minted-token",
|
||||
termixConnectId: "correlation-id",
|
||||
guacamoleConnectionId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("only reveals the live session ID to its owner", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../../hosts/guacamole/guacamole-server.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
"server.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const fn = ast.statements.find(
|
||||
(s) =>
|
||||
ts.isFunctionDeclaration(s) &&
|
||||
s.name?.text === "getGuacSessionByConnectId",
|
||||
)!;
|
||||
const info = { ownerUserId: "owner", guacamoleConnectionId: "guacd-id" };
|
||||
const context = {
|
||||
exports: {} as Record<string, (id: string, user: string) => unknown>,
|
||||
guacSessionByConnectId: new Map([["connect-id", info]]),
|
||||
};
|
||||
runInNewContext(
|
||||
ts.transpileModule(fn.getText(ast), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText,
|
||||
context,
|
||||
);
|
||||
const lookup = context.exports.getGuacSessionByConnectId;
|
||||
expect(lookup("connect-id", "owner")).toEqual(info);
|
||||
expect(lookup("connect-id", "other")).toBeNull();
|
||||
expect(lookup("pending", "owner")).toBeNull();
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
guacLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { GuacamoleTokenService } =
|
||||
await import("../../../hosts/guacamole/token-service.js");
|
||||
|
||||
describe("GuacamoleTokenService", () => {
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
it("disables RDP pre-authentication when no credentials are configured", () => {
|
||||
const token = tokenService.createRdpToken("windows.example.test", "", "");
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.settings).toMatchObject({
|
||||
hostname: "windows.example.test",
|
||||
port: 3389,
|
||||
"ignore-cert": true,
|
||||
"disable-auth": true,
|
||||
});
|
||||
expect(decrypted?.connection.settings.username).toBeUndefined();
|
||||
expect(decrypted?.connection.settings.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps normal RDP credential authentication unchanged", () => {
|
||||
const token = tokenService.createRdpToken(
|
||||
"windows.example.test",
|
||||
"Administrator",
|
||||
"secret",
|
||||
);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.settings).toMatchObject({
|
||||
hostname: "windows.example.test",
|
||||
username: "Administrator",
|
||||
password: "secret",
|
||||
port: 3389,
|
||||
});
|
||||
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves recording metadata outside guacd connection settings", () => {
|
||||
const recording = {
|
||||
hostId: 42,
|
||||
userId: "user-1",
|
||||
protocol: "vnc" as const,
|
||||
path: "recording.guac",
|
||||
startedAt: "2026-07-14T00:00:00.000Z",
|
||||
};
|
||||
const token = tokenService.createVncToken(
|
||||
"vnc.example.test",
|
||||
"user",
|
||||
"secret",
|
||||
{},
|
||||
recording,
|
||||
);
|
||||
|
||||
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
|
||||
});
|
||||
|
||||
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
|
||||
const termixMeta = {
|
||||
termixConnectId: "connect-1",
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
protocol: "rdp" as const,
|
||||
};
|
||||
const token = tokenService.createRdpToken(
|
||||
"windows.example.test",
|
||||
"Administrator",
|
||||
"secret",
|
||||
{},
|
||||
undefined,
|
||||
termixMeta,
|
||||
);
|
||||
|
||||
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
|
||||
});
|
||||
|
||||
it("createJoinToken sets connection.join, not connection.type", () => {
|
||||
const token = tokenService.createJoinToken("guacd-conn-123", true);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.join).toBe("guacd-conn-123");
|
||||
expect(decrypted?.connection.type).toBeUndefined();
|
||||
expect(decrypted?.connection.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("createJoinToken round-trips a read-write join through decryptToken", () => {
|
||||
const token = tokenService.createJoinToken("guacd-conn-456", false);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.join).toBe("guacd-conn-456");
|
||||
expect(decrypted?.connection.readOnly).toBe(false);
|
||||
expect(decrypted?.recording).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { WebSocket } from "ws";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
|
||||
import { authFailureTracker } from "../../hosts/metrics/state.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
|
||||
const { updateHostKey } = vi.hoisted(() => ({ updateHostKey: vi.fn() }));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -12,10 +12,15 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
const hostId = 991267;
|
||||
const accepted: unknown[] = [];
|
||||
const unsubscribe = pluginEvents.on(TOPICS.hostKeyUpdated, (payload) =>
|
||||
accepted.push(payload),
|
||||
);
|
||||
afterEach(() => {
|
||||
authFailureTracker.reset(hostId);
|
||||
accepted.length = 0;
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
afterAll(() => unsubscribe());
|
||||
|
||||
async function verifyChangedKey(action: "accept" | "reject") {
|
||||
const socket = new EventEmitter() as EventEmitter & {
|
||||
@@ -52,35 +57,22 @@ async function verifyChangedKey(action: "accept" | "reject") {
|
||||
);
|
||||
}
|
||||
|
||||
describe("metrics recovery after host key verification", () => {
|
||||
it("allows polling after accepting and saving the replacement key", async () => {
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
describe("host key recovery", () => {
|
||||
it("announces an accepted and saved replacement key", async () => {
|
||||
expect(await verifyChangedKey("accept")).toBe(true);
|
||||
expect(updateHostKey).toHaveBeenCalledOnce();
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(false);
|
||||
expect(accepted).toEqual([{ hostId }]);
|
||||
});
|
||||
|
||||
it("keeps polling blocked when the new key is rejected", async () => {
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
it("announces nothing when the new key is rejected", async () => {
|
||||
expect(await verifyChangedKey("reject")).toBe(false);
|
||||
expect(updateHostKey).not.toHaveBeenCalled();
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
expect(accepted).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps polling blocked if persisting the accepted key fails", async () => {
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
it("announces nothing if persisting the accepted key fails", async () => {
|
||||
updateHostKey.mockRejectedValueOnce(new Error("database unavailable"));
|
||||
expect(await verifyChangedKey("accept")).toBe(false);
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
expect(accepted).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["AUTH", "TOTP"] as const)(
|
||||
"preserves unrelated %s failures",
|
||||
async (reason) => {
|
||||
authFailureTracker.recordFailure(hostId, reason, true);
|
||||
expect(await verifyChangedKey("accept")).toBe(true);
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deriveEnabledWidgets,
|
||||
defaultLayoutFromWidgets,
|
||||
isMetricCardId,
|
||||
isManagerCardId,
|
||||
METRIC_CARD_IDS,
|
||||
} from "../../../types/host-metrics.js";
|
||||
|
||||
describe("deriveEnabledWidgets", () => {
|
||||
it("returns only metric ids, in canonical order, deduped", () => {
|
||||
const slots = [
|
||||
{ id: "firewall" },
|
||||
{ id: "cpu" },
|
||||
{ id: "cpu" },
|
||||
{ id: "memory" },
|
||||
];
|
||||
expect(deriveEnabledWidgets(slots)).toEqual(["cpu", "memory", "firewall"]);
|
||||
});
|
||||
|
||||
it("excludes manager card ids (mobile contract: never leak managers)", () => {
|
||||
const slots = [
|
||||
{ id: "cpu" },
|
||||
{ id: "service_manager" },
|
||||
{ id: "log_viewer" },
|
||||
{ id: "disk" },
|
||||
];
|
||||
const out = deriveEnabledWidgets(slots);
|
||||
expect(out).toEqual(["cpu", "disk"]);
|
||||
for (const id of out) expect(isMetricCardId(id)).toBe(true);
|
||||
});
|
||||
|
||||
it("output is always a subset of the 10 known WidgetTypes", () => {
|
||||
const slots = METRIC_CARD_IDS.map((id) => ({ id })).concat([
|
||||
{ id: "user_manager" } as { id: (typeof METRIC_CARD_IDS)[number] },
|
||||
{ id: "bogus" } as { id: (typeof METRIC_CARD_IDS)[number] },
|
||||
]);
|
||||
const out = deriveEnabledWidgets(slots);
|
||||
expect(out).toEqual(METRIC_CARD_IDS);
|
||||
expect(out.length).toBe(METRIC_CARD_IDS.length);
|
||||
});
|
||||
|
||||
it("empty slots -> empty widgets", () => {
|
||||
expect(deriveEnabledWidgets([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultLayoutFromWidgets", () => {
|
||||
it("builds slots in canonical order with dense ordering", () => {
|
||||
const layout = defaultLayoutFromWidgets(["disk", "cpu", "memory"]);
|
||||
expect(layout.slots.map((s) => s.id)).toEqual(["cpu", "memory", "disk"]);
|
||||
expect(layout.slots.map((s) => s.order)).toEqual([0, 1, 2]);
|
||||
expect(layout.columns).toBe(3);
|
||||
});
|
||||
|
||||
it("ignores unknown widget ids", () => {
|
||||
const layout = defaultLayoutFromWidgets(["cpu", "nope", "service_manager"]);
|
||||
expect(layout.slots.map((s) => s.id)).toEqual(["cpu"]);
|
||||
});
|
||||
|
||||
it("assigns valid colSpans (1..3)", () => {
|
||||
const layout = defaultLayoutFromWidgets([...METRIC_CARD_IDS]);
|
||||
for (const s of layout.slots) {
|
||||
expect([1, 2, 3]).toContain(s.colSpan);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the opt-in gpu card when deriving enabledWidgets", () => {
|
||||
expect(deriveEnabledWidgets([{ id: "gpu" }, { id: "cpu" }])).toEqual([
|
||||
"cpu",
|
||||
"gpu",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips: deriveEnabledWidgets(defaultLayout) === input order", () => {
|
||||
const layout = defaultLayoutFromWidgets(["ports", "cpu", "firewall"]);
|
||||
expect(deriveEnabledWidgets(layout.slots)).toEqual([
|
||||
"cpu",
|
||||
"ports",
|
||||
"firewall",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("card id guards", () => {
|
||||
it("classifies metric vs manager ids", () => {
|
||||
expect(isMetricCardId("cpu")).toBe(true);
|
||||
expect(isMetricCardId("service_manager")).toBe(false);
|
||||
expect(isManagerCardId("service_manager")).toBe(true);
|
||||
expect(isManagerCardId("cpu")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
findHostOwnerId,
|
||||
findHostById,
|
||||
}),
|
||||
createCurrentVaultProfileRepository: () => ({}),
|
||||
createCurrentUserRepository: () => ({ findById: vi.fn() }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ const state = vi.hoisted(() => ({
|
||||
isAdminBypass: false,
|
||||
overrideCredentialId: null as number | null,
|
||||
credentials: new Map<string, Record<string, unknown>>(),
|
||||
vaultProfile: null as Record<string, unknown> | null,
|
||||
auditCalls: [] as Record<string, unknown>[],
|
||||
folderCredentialId: null as number | null,
|
||||
sharedSecret: null as Record<string, unknown> | null,
|
||||
activeHostAccessId: null as number | null,
|
||||
snapshotForUserCalls: [] as Record<string, unknown>[],
|
||||
snapshotHeals: false,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -23,12 +25,18 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||
findCredentialId: async () => state.overrideCredentialId,
|
||||
}),
|
||||
createCurrentVaultProfileRepository: () => ({
|
||||
findById: async () => state.vaultProfile,
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (userId: string) => ({ id: userId, username: userId }),
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
listUserRoleIds: async () => [],
|
||||
}),
|
||||
createCurrentRbacAccessRepository: () => ({
|
||||
findActiveHostAccess: async () =>
|
||||
state.activeHostAccessId !== null
|
||||
? { id: state.activeHostAccessId }
|
||||
: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
@@ -52,6 +60,26 @@ vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
getSecretForUser: async () => state.sharedSecret,
|
||||
snapshotForUser: async (
|
||||
hostAccessId: number,
|
||||
hostId: number,
|
||||
targetUserId: string,
|
||||
ownerId: string,
|
||||
) => {
|
||||
state.snapshotForUserCalls.push({
|
||||
hostAccessId,
|
||||
hostId,
|
||||
targetUserId,
|
||||
ownerId,
|
||||
});
|
||||
if (state.snapshotHeals) {
|
||||
state.sharedSecret = {
|
||||
username: "shared-user",
|
||||
authType: "key",
|
||||
key: "HEALED-KEY",
|
||||
};
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -83,14 +111,9 @@ function baseHost(overrides: Record<string, unknown> = {}) {
|
||||
keyType: null,
|
||||
credentialId: null,
|
||||
shareSshAuth: false,
|
||||
vaultProfileId: null,
|
||||
sudoPassword: "owner-sudo",
|
||||
autostartPassword: "auto-pass",
|
||||
autostartKey: null,
|
||||
autostartKeyPassword: null,
|
||||
jumpHosts: null,
|
||||
tunnelConnections: null,
|
||||
statsConfig: null,
|
||||
terminalConfig: null,
|
||||
socks5ProxyChain: null,
|
||||
quickActions: null,
|
||||
@@ -105,10 +128,12 @@ beforeEach(() => {
|
||||
state.isAdminBypass = false;
|
||||
state.overrideCredentialId = null;
|
||||
state.credentials.clear();
|
||||
state.vaultProfile = null;
|
||||
state.auditCalls = [];
|
||||
state.folderCredentialId = null;
|
||||
state.sharedSecret = null;
|
||||
state.activeHostAccessId = null;
|
||||
state.snapshotForUserCalls = [];
|
||||
state.snapshotHeals = false;
|
||||
});
|
||||
|
||||
describe("resolveHostById", () => {
|
||||
@@ -224,11 +249,34 @@ describe("resolveHostById", () => {
|
||||
expect(host.authType).toBe("password");
|
||||
});
|
||||
|
||||
it("denies shared secret-backed auth when the opted-in snapshot is missing", async () => {
|
||||
it("denies shared secret-backed auth when the opted-in snapshot is missing and cannot be healed", async () => {
|
||||
state.host = baseHost({ shareSshAuth: true });
|
||||
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||
});
|
||||
|
||||
it("lazily re-snapshots and resolves shared auth when the grant exists but its snapshot was never created", async () => {
|
||||
// e.g. the owner shared the host while the recipient's data key was
|
||||
// unavailable, so snapshotForUser silently skipped at share time.
|
||||
state.host = baseHost({ shareSshAuth: true, username: "host-user" });
|
||||
state.activeHostAccessId = 77;
|
||||
state.snapshotHeals = true;
|
||||
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(state.snapshotForUserCalls).toEqual([
|
||||
{
|
||||
hostAccessId: 77,
|
||||
hostId: 42,
|
||||
targetUserId: "recipient",
|
||||
ownerId: "owner",
|
||||
},
|
||||
]);
|
||||
expect(host.key).toBe("HEALED-KEY");
|
||||
expect(host.authType).toBe("key");
|
||||
});
|
||||
|
||||
it("keeps SSH agent authentication private unless the owner opts in", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "agent",
|
||||
@@ -256,9 +304,11 @@ describe("resolveHostById", () => {
|
||||
unknown
|
||||
>;
|
||||
expect(host.authType).toBe("agent");
|
||||
expect(host.sshOptions).toEqual({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
});
|
||||
expect(host.terminalConfig).toEqual({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
sudoPassword: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,13 +365,11 @@ describe("resolveHostById", () => {
|
||||
expect(host.key).toBe("RECIPIENT-KEY");
|
||||
});
|
||||
|
||||
it("fully replaces Vault authentication with the recipient override", async () => {
|
||||
it("fully replaces a plugin auth type with the recipient override", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "vault",
|
||||
password: null,
|
||||
vaultProfileId: 7,
|
||||
});
|
||||
state.vaultProfile = { id: 7 };
|
||||
state.overrideCredentialId = 5;
|
||||
state.credentials.set("5:recipient", {
|
||||
id: 5,
|
||||
@@ -342,7 +390,6 @@ describe("resolveHostById", () => {
|
||||
expect(host.authType).toBe("key");
|
||||
expect(host.key).toBe("RECIPIENT-KEY");
|
||||
expect(host.certPublicKey).toBe("ssh-ed25519-cert-v01@example certificate");
|
||||
expect(host.vaultProfile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the host username when the override credential has none", async () => {
|
||||
@@ -401,10 +448,43 @@ describe("resolveHostById", () => {
|
||||
expect(host.password).toBeNull();
|
||||
expect(host.key).toBeNull();
|
||||
expect(host.credentialId).toBeNull();
|
||||
expect(host.terminalConfig).toEqual({
|
||||
theme: "termix",
|
||||
// The owner's legacy sudo password never reaches a recipient.
|
||||
expect(host.terminalConfig).toEqual({ theme: "termix" });
|
||||
expect(host.sudoPassword).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the owner a 2.8 sudo password and SSH options kept in terminal_config", async () => {
|
||||
state.host = baseHost({
|
||||
sudoPassword: null,
|
||||
sshOptions: null,
|
||||
terminalConfig: JSON.stringify({
|
||||
sudoPassword: "legacy-sudo",
|
||||
keepaliveInterval: 12,
|
||||
theme: "nord",
|
||||
}),
|
||||
});
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.sudoPassword).toBe("legacy-sudo");
|
||||
expect(host.sshOptions).toEqual({ keepaliveInterval: 12 });
|
||||
expect(host.terminalConfig).toEqual({
|
||||
keepaliveInterval: 12,
|
||||
theme: "nord",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the ssh_options column once it is filled", async () => {
|
||||
state.host = baseHost({
|
||||
sshOptions: JSON.stringify({ keepaliveCountMax: 3 }),
|
||||
terminalConfig: JSON.stringify({ keepaliveInterval: 12 }),
|
||||
});
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.sshOptions).toEqual({ keepaliveCountMax: 3 });
|
||||
});
|
||||
|
||||
it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => {
|
||||
@@ -436,7 +516,6 @@ describe("resolveHostById", () => {
|
||||
expect(host.username).toBe("cred-user");
|
||||
// Owner-only operational secrets are NOT stripped for the admin.
|
||||
expect(host.sudoPassword).toBe("owner-sudo");
|
||||
expect(host.autostartPassword).toBe("auto-pass");
|
||||
});
|
||||
|
||||
it("audits every admin-bypass host resolution", async () => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HostSessionStatus } from "../../hosts/host-session-status.js";
|
||||
|
||||
describe("HostSessionStatus", () => {
|
||||
it("reports only the first connection and last disconnection per host", () => {
|
||||
const status = new HostSessionStatus();
|
||||
const listener = vi.fn();
|
||||
status.subscribe(listener);
|
||||
|
||||
const closeFirst = status.register(7);
|
||||
const closeSecond = status.register(7);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith(7, true);
|
||||
|
||||
closeFirst();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
closeSecond();
|
||||
expect(listener).toHaveBeenLastCalledWith(7, false);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("makes connection cleanup idempotent", () => {
|
||||
const status = new HostSessionStatus();
|
||||
const listener = vi.fn();
|
||||
status.subscribe(listener);
|
||||
|
||||
const close = status.register(9);
|
||||
close();
|
||||
close();
|
||||
|
||||
expect(listener.mock.calls).toEqual([
|
||||
[9, true],
|
||||
[9, false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getJumpHostSocks5Config } from "../../hosts/jump-host-proxy.js";
|
||||
|
||||
describe("getJumpHostSocks5Config", () => {
|
||||
it("uses the first jump host proxy settings", () => {
|
||||
expect(
|
||||
getJumpHostSocks5Config({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy.internal",
|
||||
socks5Port: 1080,
|
||||
socks5Username: "user",
|
||||
socks5Password: "secret",
|
||||
}),
|
||||
).toEqual({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy.internal",
|
||||
socks5Port: 1080,
|
||||
socks5Username: "user",
|
||||
socks5Password: "secret",
|
||||
socks5ProxyChain: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not use destination proxy settings for the first jump host", () => {
|
||||
expect(getJumpHostSocks5Config({ useSocks5: false })).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a serialized proxy chain from the first jump host", () => {
|
||||
const chain = [
|
||||
{
|
||||
id: "proxy-1",
|
||||
name: "Proxy 1",
|
||||
host: "proxy.internal",
|
||||
port: 1080,
|
||||
type: "socks5" as const,
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getJumpHostSocks5Config({
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: JSON.stringify(chain),
|
||||
}),
|
||||
).toEqual({
|
||||
useSocks5: true,
|
||||
socks5Host: undefined,
|
||||
socks5Port: undefined,
|
||||
socks5Username: undefined,
|
||||
socks5Password: undefined,
|
||||
socks5ProxyChain: chain,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildSudoCommand,
|
||||
shellSingleQuote,
|
||||
} from "../../../hosts/metrics/managers/exec-elevated.js";
|
||||
import { parsePlatformProbe } from "../../../hosts/metrics/managers/platform.js";
|
||||
import {
|
||||
isValidSystemdUnit,
|
||||
isValidPid,
|
||||
isValidPort,
|
||||
isValidPackageName,
|
||||
isValidUsername,
|
||||
isValidDomain,
|
||||
isValidDnsProvider,
|
||||
isValidSignal,
|
||||
isValidServiceAction,
|
||||
isAllowedPath,
|
||||
} from "../../../hosts/metrics/managers/validation.js";
|
||||
import {
|
||||
parseServiceList,
|
||||
buildServiceActionCommand,
|
||||
} from "../../../hosts/metrics/managers/services.js";
|
||||
import {
|
||||
parseProcessList,
|
||||
buildKillCommand,
|
||||
} from "../../../hosts/metrics/managers/processes.js";
|
||||
import {
|
||||
parseDfMounts,
|
||||
parseTopMemory,
|
||||
} from "../../../hosts/metrics/managers/simple-reads.js";
|
||||
import {
|
||||
parseCrontab,
|
||||
serializeCrontab,
|
||||
isValidCronSchedule,
|
||||
buildApplyCrontabCommand,
|
||||
} from "../../../hosts/metrics/managers/cron.js";
|
||||
import {
|
||||
buildPackageActionCommand,
|
||||
parseUpgradable,
|
||||
buildListUpgradableCommand,
|
||||
} from "../../../hosts/metrics/managers/packages.js";
|
||||
import {
|
||||
buildIssueCommand,
|
||||
buildRenewCommand,
|
||||
buildRevokeCommand,
|
||||
isValidCertName,
|
||||
parseCertbotCertificates,
|
||||
} from "../../../hosts/metrics/managers/ssl.js";
|
||||
import {
|
||||
buildIptablesRuleCommand,
|
||||
buildNftRuleCommand,
|
||||
} from "../../../hosts/metrics/managers/firewall.js";
|
||||
import {
|
||||
parsePasswd,
|
||||
parseSudoers,
|
||||
} from "../../../hosts/metrics/managers/users.js";
|
||||
import {
|
||||
buildHealthCheckCommand,
|
||||
parseHealthResult,
|
||||
} from "../../../hosts/metrics/managers/health.js";
|
||||
import {
|
||||
buildTailCommand,
|
||||
clampLines,
|
||||
} from "../../../hosts/metrics/managers/logs.js";
|
||||
|
||||
describe("exec-elevated", () => {
|
||||
it("single-quotes and escapes for the shell", () => {
|
||||
expect(shellSingleQuote("abc")).toBe("'abc'");
|
||||
expect(shellSingleQuote("a'b")).toBe(`'a'"'"'b'`);
|
||||
});
|
||||
it("builds the sudo -S pipeline wrapping the command in sh -c with a success marker", () => {
|
||||
expect(buildSudoCommand("systemctl restart nginx", "pw")).toBe(
|
||||
`echo 'pw' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; systemctl restart nginx'`,
|
||||
);
|
||||
});
|
||||
it("does not merge stderr into stdout (no 2>&1)", () => {
|
||||
expect(buildSudoCommand("id", "pw")).not.toContain("2>&1");
|
||||
});
|
||||
it("escapes a password containing a quote", () => {
|
||||
expect(buildSudoCommand("id", "p'w")).toContain(`echo 'p'"'"'w'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform probe parsing", () => {
|
||||
it("parses capabilities and prefers dnf over yum", () => {
|
||||
const out = [
|
||||
"systemd=1",
|
||||
"apt=0",
|
||||
"dnf=1",
|
||||
"yum=1",
|
||||
"pacman=0",
|
||||
"certbot=1",
|
||||
"acmesh=0",
|
||||
"docker=1",
|
||||
"os=Fedora Linux 40",
|
||||
].join("\n");
|
||||
const p = parsePlatformProbe(out);
|
||||
expect(p.hasSystemd).toBe(true);
|
||||
expect(p.pkg).toBe("dnf");
|
||||
expect(p.hasCertbot).toBe(true);
|
||||
expect(p.hasAcmeSh).toBe(false);
|
||||
expect(p.hasDocker).toBe(true);
|
||||
expect(p.osPrettyName).toBe("Fedora Linux 40");
|
||||
});
|
||||
it("picks apt when present", () => {
|
||||
expect(parsePlatformProbe("apt=1\ndnf=1").pkg).toBe("apt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation (injection defense)", () => {
|
||||
it("systemd units", () => {
|
||||
expect(isValidSystemdUnit("nginx.service")).toBe(true);
|
||||
expect(isValidSystemdUnit("ssh.socket")).toBe(true);
|
||||
expect(isValidSystemdUnit("nginx.service; rm -rf /")).toBe(false);
|
||||
expect(isValidSystemdUnit("nginx")).toBe(false);
|
||||
});
|
||||
it("pids", () => {
|
||||
expect(isValidPid(123)).toBe(true);
|
||||
expect(isValidPid("123")).toBe(true);
|
||||
expect(isValidPid(0)).toBe(false);
|
||||
expect(isValidPid("1; reboot")).toBe(false);
|
||||
expect(isValidPid(-5)).toBe(false);
|
||||
});
|
||||
it("ports", () => {
|
||||
expect(isValidPort(443)).toBe(true);
|
||||
expect(isValidPort(0)).toBe(false);
|
||||
expect(isValidPort(70000)).toBe(false);
|
||||
});
|
||||
it("package names", () => {
|
||||
expect(isValidPackageName("nginx")).toBe(true);
|
||||
expect(isValidPackageName("lib-foo.bar+1")).toBe(true);
|
||||
expect(isValidPackageName("nginx && curl evil")).toBe(false);
|
||||
expect(isValidPackageName("-rf")).toBe(false);
|
||||
});
|
||||
it("usernames and domains", () => {
|
||||
expect(isValidUsername("deploy")).toBe(true);
|
||||
expect(isValidUsername("root; rm")).toBe(false);
|
||||
expect(isValidDomain("example.com")).toBe(true);
|
||||
expect(isValidDomain("*.example.com")).toBe(true);
|
||||
expect(isValidDomain("ex ample.com")).toBe(false);
|
||||
expect(isValidDomain("a;b.com")).toBe(false);
|
||||
});
|
||||
it("dns providers, signals, service actions", () => {
|
||||
expect(isValidDnsProvider("cloudflare")).toBe(true);
|
||||
expect(isValidDnsProvider("cf; rm")).toBe(false);
|
||||
expect(isValidSignal("KILL")).toBe(true);
|
||||
expect(isValidSignal("BOOM")).toBe(false);
|
||||
expect(isValidServiceAction("restart")).toBe(true);
|
||||
expect(isValidServiceAction("destroy")).toBe(false);
|
||||
});
|
||||
it("path allowlist rejects traversal and out-of-allowlist", () => {
|
||||
expect(isAllowedPath("/var/log/syslog", ["/var/log"])).toBe(true);
|
||||
expect(isAllowedPath("/var/log/../../etc/passwd", ["/var/log"])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isAllowedPath("/etc/passwd", ["/var/log"])).toBe(false);
|
||||
expect(isAllowedPath("relative/path", ["/var/log"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("services", () => {
|
||||
it("parses list-units --plain", () => {
|
||||
const out =
|
||||
"nginx.service loaded active running A high performance web server\n" +
|
||||
"ssh.service loaded active running OpenBSD Secure Shell server\n" +
|
||||
"cron.service loaded inactive dead Regular background program";
|
||||
const rows = parseServiceList(out);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[0]).toMatchObject({ unit: "nginx.service", active: "active" });
|
||||
expect(rows[2].active).toBe("inactive");
|
||||
});
|
||||
it("builds action command", () => {
|
||||
expect(buildServiceActionCommand("nginx.service", "restart")).toBe(
|
||||
"systemctl restart nginx.service",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processes", () => {
|
||||
it("parses ps output", () => {
|
||||
const out =
|
||||
"1234 1 root 12.5 3.2 45000 S /usr/bin/node node server.js\n" +
|
||||
"5678 1234 deploy 0.0 1.1 12000 Sl bash -bash";
|
||||
const rows = parseProcessList(out);
|
||||
expect(rows[0]).toMatchObject({
|
||||
pid: 1234,
|
||||
user: "root",
|
||||
cpu: 12.5,
|
||||
command: "/usr/bin/node",
|
||||
});
|
||||
expect(rows[1].pid).toBe(5678);
|
||||
});
|
||||
it("builds kill command", () => {
|
||||
expect(buildKillCommand(42, "TERM")).toBe("kill -TERM 42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("simple reads", () => {
|
||||
it("parses df -Pk and skips tmpfs", () => {
|
||||
const out =
|
||||
"/dev/sda1 100000 40000 60000 40% /\n" +
|
||||
"tmpfs 8000 0 8000 0% /dev/shm\n" +
|
||||
"/dev/sdb1 200000 100000 100000 50% /data";
|
||||
const mounts = parseDfMounts(out);
|
||||
expect(mounts).toHaveLength(2);
|
||||
expect(mounts[0].mount).toBe("/");
|
||||
expect(mounts[1].usePct).toBe(50);
|
||||
});
|
||||
it("parses top by memory", () => {
|
||||
const rows = parseTopMemory("1234 root 5.5 50000 node");
|
||||
expect(rows[0]).toMatchObject({ pid: 1234, mem: 5.5, command: "node" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron", () => {
|
||||
it("parses enabled and toggled entries", () => {
|
||||
const out = "0 2 * * * /backup.sh\n# 30 4 * * * /old.sh\nPATH=/usr/bin";
|
||||
const entries = parseCrontab(out);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toMatchObject({ enabled: true, schedule: "0 2 * * *" });
|
||||
expect(entries[1].enabled).toBe(false);
|
||||
});
|
||||
it("validates schedules", () => {
|
||||
expect(isValidCronSchedule("0 2 * * *")).toBe(true);
|
||||
expect(isValidCronSchedule("@daily")).toBe(true);
|
||||
expect(isValidCronSchedule("not a schedule")).toBe(false);
|
||||
});
|
||||
it("serializes (commenting disabled entries) and round-trips", () => {
|
||||
const body = serializeCrontab([
|
||||
{ raw: "", enabled: true, schedule: "0 2 * * *", command: "/a.sh" },
|
||||
{ raw: "", enabled: false, schedule: "@daily", command: "/b.sh" },
|
||||
]);
|
||||
expect(body).toBe("0 2 * * * /a.sh\n# @daily /b.sh\n");
|
||||
const reparsed = parseCrontab(body);
|
||||
expect(reparsed[0].enabled).toBe(true);
|
||||
expect(reparsed[1].enabled).toBe(false);
|
||||
});
|
||||
it("builds apply command piping into crontab -", () => {
|
||||
expect(buildApplyCrontabCommand("x\n")).toBe(
|
||||
`printf '%s' 'x\n' | crontab -`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("packages", () => {
|
||||
it("builds per-distro commands", () => {
|
||||
expect(buildPackageActionCommand("apt", "install", "nginx")).toContain(
|
||||
"apt-get -y install nginx",
|
||||
);
|
||||
expect(buildPackageActionCommand("pacman", "upgrade-all")).toBe(
|
||||
"pacman -Syu --noconfirm",
|
||||
);
|
||||
expect(buildPackageActionCommand(null, "install", "x")).toBeNull();
|
||||
});
|
||||
it("lists per distro", () => {
|
||||
expect(buildListUpgradableCommand("apt")).toContain(
|
||||
"apt list --upgradable",
|
||||
);
|
||||
expect(buildListUpgradableCommand(null)).toBeNull();
|
||||
});
|
||||
it("parses apt upgradable", () => {
|
||||
const out =
|
||||
"nginx/focal-updates 1.18.0-2 amd64 [upgradable from: 1.18.0-1]";
|
||||
const pkgs = parseUpgradable("apt", out);
|
||||
expect(pkgs[0]).toMatchObject({
|
||||
name: "nginx",
|
||||
newVersion: "1.18.0-2",
|
||||
currentVersion: "1.18.0-1",
|
||||
});
|
||||
});
|
||||
it("parses pacman upgradable", () => {
|
||||
const pkgs = parseUpgradable("pacman", "linux 6.1 -> 6.2");
|
||||
expect(pkgs[0]).toMatchObject({ name: "linux", newVersion: "6.2" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssl (dual client)", () => {
|
||||
it("builds certbot issue for http + dns", () => {
|
||||
expect(
|
||||
buildIssueCommand({
|
||||
client: "certbot",
|
||||
domains: ["a.com"],
|
||||
challenge: "http-standalone",
|
||||
}),
|
||||
).toContain(
|
||||
"certbot certonly --non-interactive --agree-tos --standalone -d 'a.com'",
|
||||
);
|
||||
expect(
|
||||
buildIssueCommand({
|
||||
client: "certbot",
|
||||
domains: ["a.com"],
|
||||
challenge: "dns",
|
||||
dnsProvider: "cloudflare",
|
||||
}),
|
||||
).toContain("--dns-cloudflare");
|
||||
});
|
||||
it("builds acme.sh issue", () => {
|
||||
const cmd = buildIssueCommand({
|
||||
client: "acme.sh",
|
||||
domains: ["a.com", "b.com"],
|
||||
challenge: "dns",
|
||||
dnsProvider: "cf",
|
||||
});
|
||||
expect(cmd).toContain("--issue --dns dns_cf");
|
||||
expect(cmd).toContain("-d 'a.com'");
|
||||
expect(cmd).toContain("-d 'b.com'");
|
||||
});
|
||||
it("builds renew per client", () => {
|
||||
expect(buildRenewCommand("certbot", true)).toBe("certbot renew --dry-run");
|
||||
expect(buildRenewCommand("acme.sh", false)).toContain("--renew-all");
|
||||
});
|
||||
it("builds revoke per client", () => {
|
||||
expect(buildRevokeCommand("certbot", "example.com")).toBe(
|
||||
"certbot revoke --non-interactive --cert-name 'example.com' --delete-after-revoke",
|
||||
);
|
||||
const acme = buildRevokeCommand("acme.sh", "example.com");
|
||||
expect(acme).toContain("--revoke -d 'example.com'");
|
||||
expect(acme).toContain("--remove -d 'example.com'");
|
||||
});
|
||||
it("validates certificate names (rejects shell metachars)", () => {
|
||||
expect(isValidCertName("example.com")).toBe(true);
|
||||
expect(isValidCertName("example.com-0001")).toBe(true);
|
||||
expect(isValidCertName("*.example.com")).toBe(true);
|
||||
expect(isValidCertName("a.com; rm -rf /")).toBe(false);
|
||||
expect(isValidCertName("")).toBe(false);
|
||||
expect(isValidCertName(undefined)).toBe(false);
|
||||
});
|
||||
it("parses certbot certificates", () => {
|
||||
const out = [
|
||||
"Found the following certs:",
|
||||
" Certificate Name: example.com",
|
||||
" Domains: example.com www.example.com",
|
||||
" Expiry Date: 2026-09-01 12:00:00+00:00 (VALID: 80 days)",
|
||||
" Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem",
|
||||
].join("\n");
|
||||
const certs = parseCertbotCertificates(out);
|
||||
expect(certs[0]).toMatchObject({
|
||||
name: "example.com",
|
||||
client: "certbot",
|
||||
});
|
||||
expect(certs[0].domains).toContain("www.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("firewall", () => {
|
||||
it("builds iptables add/delete on INPUT only", () => {
|
||||
expect(
|
||||
buildIptablesRuleCommand("add", {
|
||||
protocol: "tcp",
|
||||
port: 443,
|
||||
target: "ACCEPT",
|
||||
}),
|
||||
).toBe("iptables -A INPUT -p tcp --dport 443 -j ACCEPT");
|
||||
expect(
|
||||
buildIptablesRuleCommand("delete", {
|
||||
protocol: "udp",
|
||||
port: 53,
|
||||
target: "DROP",
|
||||
}),
|
||||
).toBe("iptables -D INPUT -p udp --dport 53 -j DROP");
|
||||
});
|
||||
it("builds nft rules", () => {
|
||||
expect(
|
||||
buildNftRuleCommand("add", {
|
||||
protocol: "tcp",
|
||||
port: 22,
|
||||
target: "ACCEPT",
|
||||
}),
|
||||
).toContain("add rule inet filter input tcp dport 22 accept");
|
||||
});
|
||||
});
|
||||
|
||||
describe("users", () => {
|
||||
it("parses passwd for human users only", () => {
|
||||
const out =
|
||||
"root:x:0:0:root:/root:/bin/bash\n" +
|
||||
"deploy:x:1000:1000:Deploy:/home/deploy:/bin/bash\n" +
|
||||
"nobody:x:65534:65534:nobody:/:/usr/sbin/nologin";
|
||||
const users = parsePasswd(out);
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0].name).toBe("deploy");
|
||||
});
|
||||
it("parses sudoers membership", () => {
|
||||
const out = "sudo:x:27:deploy,alice\nwheel:x:10:bob";
|
||||
expect(parseSudoers(out).sort()).toEqual(["alice", "bob", "deploy"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("health checks", () => {
|
||||
it("builds tcp and http commands", () => {
|
||||
const tcp = buildHealthCheckCommand({
|
||||
id: "1",
|
||||
name: "ssh",
|
||||
type: "tcp",
|
||||
target: "localhost",
|
||||
port: 22,
|
||||
});
|
||||
expect(tcp).toContain("/dev/tcp/");
|
||||
const http = buildHealthCheckCommand({
|
||||
id: "2",
|
||||
name: "web",
|
||||
type: "http",
|
||||
target: "example.com",
|
||||
path: "/health",
|
||||
});
|
||||
expect(http).toContain("curl -s -o /dev/null");
|
||||
expect(http).toContain("http://example.com/health");
|
||||
});
|
||||
it("parses tcp and http results", () => {
|
||||
const tcp = parseHealthResult(
|
||||
{ id: "1", name: "ssh", type: "tcp", target: "h", port: 22 },
|
||||
"ok 12",
|
||||
);
|
||||
expect(tcp).toMatchObject({ ok: true, latencyMs: 12 });
|
||||
const http = parseHealthResult(
|
||||
{ id: "2", name: "web", type: "http", target: "h" },
|
||||
"200 0.045",
|
||||
);
|
||||
expect(http).toMatchObject({ ok: true, latencyMs: 45 });
|
||||
const bad = parseHealthResult(
|
||||
{ id: "3", name: "web", type: "http", target: "h" },
|
||||
"500 0.01",
|
||||
);
|
||||
expect(bad.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logs", () => {
|
||||
it("clamps line counts", () => {
|
||||
expect(clampLines(50)).toBe(50);
|
||||
expect(clampLines(99999)).toBe(2000);
|
||||
expect(clampLines("abc")).toBe(200);
|
||||
expect(clampLines(0)).toBe(1);
|
||||
});
|
||||
it("builds a quoted tail command without suppressing stderr", () => {
|
||||
expect(buildTailCommand("/var/log/syslog", 100)).toBe(
|
||||
"tail -n 100 '/var/log/syslog'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Once the alert rules have been migrated into automations, both systems hold
|
||||
* a copy of every rule. If the old engine kept evaluating, every alert would
|
||||
* be delivered twice.
|
||||
*/
|
||||
|
||||
const listEnabledRulesForHost = vi.fn();
|
||||
const listEnabledRulesForHostUser = vi.fn();
|
||||
const createFiring = vi.fn();
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentAlertRepository: () => ({
|
||||
listEnabledRulesForHost,
|
||||
listEnabledRulesForHostUser,
|
||||
createFiring,
|
||||
pruneFiringsOlderThan: vi.fn(),
|
||||
listEnabledChannelsForRule: vi.fn(async () => []),
|
||||
findRuleById: vi.fn(async () => null),
|
||||
getHostDisplayName: vi.fn(async () => "host"),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
statsLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/notification-sender.js", () => ({
|
||||
sendNotification: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/discord-sender.js", () => ({
|
||||
sendDiscord: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const alertEngineModule =
|
||||
await import("../../../hosts/metrics/alert-engine.js");
|
||||
|
||||
const cpuRule = {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 50,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listEnabledRulesForHost.mockResolvedValue([cpuRule]);
|
||||
listEnabledRulesForHostUser.mockResolvedValue([cpuRule]);
|
||||
});
|
||||
|
||||
describe("AlertEngine before migration", () => {
|
||||
it("still evaluates rules", async () => {
|
||||
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(false);
|
||||
|
||||
await alertEngineModule.AlertEngine.getInstance().evaluateMetrics(7, {
|
||||
cpu: { percent: 90 },
|
||||
});
|
||||
|
||||
expect(listEnabledRulesForHost).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertEngine after migration", () => {
|
||||
it("stops evaluating every trigger type", async () => {
|
||||
alertEngineModule.markAlertEngineSuperseded();
|
||||
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(true);
|
||||
|
||||
const engine = alertEngineModule.AlertEngine.getInstance();
|
||||
await engine.evaluateMetrics(7, { cpu: { percent: 99 } });
|
||||
await engine.evaluateStatus(7, false);
|
||||
await engine.evaluateHealthCheck(7, "user-1", "web", false);
|
||||
await engine.evaluateUserLogin(7, "user-1", "root", "10.0.0.1");
|
||||
|
||||
// Nothing is even loaded, so nothing can be delivered a second time.
|
||||
expect(listEnabledRulesForHost).not.toHaveBeenCalled();
|
||||
expect(listEnabledRulesForHostUser).not.toHaveBeenCalled();
|
||||
expect(createFiring).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
supportsMetrics,
|
||||
isTcpPingEnabled,
|
||||
parseStatusHostIds,
|
||||
tcpPingThroughJumpHost,
|
||||
} from "../../../hosts/metrics/helpers.js";
|
||||
import { createConnectionLog } from "../../../hosts/connection-log.js";
|
||||
|
||||
describe("supportsMetrics", () => {
|
||||
it("supports plain ssh hosts", () => {
|
||||
expect(
|
||||
supportsMetrics({ connectionType: "ssh", authType: "password" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults missing connectionType to ssh", () => {
|
||||
expect(supportsMetrics({ authType: "key" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-ssh connection types", () => {
|
||||
expect(supportsMetrics({ connectionType: "rdp" })).toBe(false);
|
||||
expect(supportsMetrics({ connectionType: "vnc" })).toBe(false);
|
||||
expect(supportsMetrics({ connectionType: "telnet" })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects ssh hosts that cannot run shell commands", () => {
|
||||
expect(supportsMetrics({ connectionType: "ssh", authType: "none" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(supportsMetrics({ connectionType: "ssh", authType: "opkssh" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTcpPingEnabled", () => {
|
||||
it("is enabled when status checks are on and tcp ping is not disabled", () => {
|
||||
expect(
|
||||
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: false }),
|
||||
).toBe(true);
|
||||
expect(isTcpPingEnabled({ statusCheckEnabled: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("is disabled when status checks are off", () => {
|
||||
expect(isTcpPingEnabled({ statusCheckEnabled: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("is disabled when tcp ping is explicitly disabled", () => {
|
||||
expect(
|
||||
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStatusHostIds", () => {
|
||||
it("distinguishes an unrestricted request from an empty host set", () => {
|
||||
expect(parseStatusHostIds(undefined)).toBeNull();
|
||||
expect(parseStatusHostIds("")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("keeps only valid positive host IDs", () => {
|
||||
expect(parseStatusHostIds("7,2,7,-1,nope,1.5")).toEqual(new Set([7, 2]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConnectionLog", () => {
|
||||
it("builds a log entry without id/timestamp", () => {
|
||||
const entry = createConnectionLog("info", "connection", "Connecting", {
|
||||
hostId: 1,
|
||||
});
|
||||
expect(entry).toEqual({
|
||||
type: "info",
|
||||
stage: "connection",
|
||||
message: "Connecting",
|
||||
details: { hostId: 1 },
|
||||
});
|
||||
expect("id" in entry).toBe(false);
|
||||
expect("timestamp" in entry).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tcpPingThroughJumpHost", () => {
|
||||
it("reports the final destination online when forwarding succeeds", async () => {
|
||||
const stream = { destroy: vi.fn() };
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
|
||||
expect(host).toBe("private.example");
|
||||
expect(port).toBe(22);
|
||||
callback(undefined, stream);
|
||||
}),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
await expect(
|
||||
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
|
||||
).resolves.toBe(true);
|
||||
expect(stream.destroy).toHaveBeenCalledOnce();
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports the final destination offline when forwarding fails", async () => {
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
|
||||
callback(new Error("Connection refused"));
|
||||
}),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
await expect(
|
||||
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
|
||||
).resolves.toBe(false);
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports the final destination offline when forwarding times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn(),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
const result = tcpPingThroughJumpHost(
|
||||
jumpClient,
|
||||
"private.example",
|
||||
22,
|
||||
5000,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
// Load the production route without starting metrics polling or SSH services.
|
||||
const source = readFileSync(
|
||||
new URL("../../../hosts/metrics/index.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
"index.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const route = ast.statements.find(
|
||||
(node) =>
|
||||
ts.isExpressionStatement(node) &&
|
||||
ts.isCallExpression(node.expression) &&
|
||||
node.expression.expression.getText(ast) === "app.post" &&
|
||||
node.expression.arguments[0]?.getText(ast) === '"/internal/login-alert"',
|
||||
);
|
||||
if (!route) throw new Error("Login event route is missing");
|
||||
const notify = vi.fn();
|
||||
const legacy = vi.fn().mockResolvedValue(undefined);
|
||||
let handler: (req: unknown, res: unknown) => Promise<void>;
|
||||
runInNewContext(
|
||||
ts.transpileModule(route.getText(ast), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText,
|
||||
{
|
||||
app: {
|
||||
post: (_path: string, callback: typeof handler) => {
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
require: () => ({
|
||||
SystemCrypto: {
|
||||
getInstance: () => ({
|
||||
getInternalAuthToken: async () => "internal-test-token",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
notifyAutomationInternalEvent: notify,
|
||||
AlertEngine: { getInstance: () => ({ evaluateUserLogin: legacy }) },
|
||||
},
|
||||
);
|
||||
const body = {
|
||||
hostId: 42,
|
||||
userId: "owner",
|
||||
sshUser: "alice",
|
||||
fromIp: "192.0.2.4",
|
||||
};
|
||||
const response = () => ({ status: vi.fn().mockReturnThis(), json: vi.fn() });
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("delivers successful SSH login details to automations while retaining the legacy hook", async () => {
|
||||
const res = response();
|
||||
await handler(
|
||||
{
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: { "x-internal-auth": "internal-test-token" },
|
||||
body,
|
||||
},
|
||||
res,
|
||||
);
|
||||
expect(notify).toHaveBeenCalledExactlyOnceWith("user_login", "owner", 42, {
|
||||
sshUser: "alice",
|
||||
fromIp: "192.0.2.4",
|
||||
});
|
||||
expect(legacy).toHaveBeenCalledExactlyOnceWith(
|
||||
42,
|
||||
"owner",
|
||||
"alice",
|
||||
"192.0.2.4",
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith({ ok: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["192.0.2.9", "internal-test-token"],
|
||||
["127.0.0.1", "incorrect"],
|
||||
])(
|
||||
"rejects untrusted login notifications (%s)",
|
||||
async (remoteAddress, token) => {
|
||||
const res = response();
|
||||
await handler(
|
||||
{
|
||||
socket: { remoteAddress },
|
||||
headers: { "x-internal-auth": token },
|
||||
body,
|
||||
},
|
||||
res,
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(legacy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// Regression guard for: the /internal/login-alert route was registered
|
||||
// after the global JWT auth middleware, so every service-to-service login
|
||||
// alert got rejected with 401 before the route's own IP+token check ever
|
||||
// ran. Spinning up the full metrics-service Express app (DB, SSH clients,
|
||||
// polling managers, etc.) just to hit this one route is out of scope, so
|
||||
// this asserts the registration order directly against the source instead.
|
||||
describe("metrics service /internal/login-alert route order", () => {
|
||||
it("is registered before the global auth middleware", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../../hosts/metrics/index.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const routeIndex = source.indexOf('app.post("/internal/login-alert"');
|
||||
const authMiddlewareIndex = source.indexOf(
|
||||
"app.use(authManager.createAuthMiddleware())",
|
||||
);
|
||||
|
||||
expect(routeIndex).toBeGreaterThan(-1);
|
||||
expect(authMiddlewareIndex).toBeGreaterThan(-1);
|
||||
expect(routeIndex).toBeLessThan(authMiddlewareIndex);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
execElevated,
|
||||
ElevationError,
|
||||
} from "../../../../hosts/metrics/managers/exec-elevated.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, stderr = "", code: number | null = 0) {
|
||||
return { stdout, stderr, code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("execElevated (no force)", () => {
|
||||
it("returns the direct result when the command succeeds unprivileged", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("hello", "", 0));
|
||||
const r = await execElevated(fakeClient, "echo hello", "pw");
|
||||
expect(r.usedSudo).toBe(false);
|
||||
expect(r.stdout).toBe("hello");
|
||||
expect(execCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT escalate when stdout merely contains scary words", async () => {
|
||||
// A non-zero exit whose OUTPUT contains 'permission denied' but stderr does
|
||||
// not should be surfaced as-is, never retried under sudo.
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("log line: permission denied for user foo", "", 1),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "grep denied /tmp/app.log", "pw");
|
||||
expect(r.usedSudo).toBe(false);
|
||||
expect(execCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("escalates when stderr indicates a permission problem", async () => {
|
||||
execCommand
|
||||
.mockResolvedValueOnce(result("", "Permission denied", 1))
|
||||
.mockResolvedValueOnce(result("__TX_SUDO_OK__\nelevated output", "", 0));
|
||||
const r = await execElevated(fakeClient, "cat /etc/shadow", "pw");
|
||||
expect(r.usedSudo).toBe(true);
|
||||
expect(r.stdout).toBe("elevated output");
|
||||
expect(execCommand).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws SUDO_REQUIRED when elevation is needed but no password is set", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", "Permission denied", 1));
|
||||
await expect(
|
||||
execElevated(fakeClient, "cat /etc/shadow", undefined),
|
||||
).rejects.toMatchObject({ code: "SUDO_REQUIRED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("execElevated (forced)", () => {
|
||||
it("strips the success marker from stdout", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("__TX_SUDO_OK__\nthe real output\n", "", 0),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "id", "pw", { forceSudo: true });
|
||||
expect(r.stdout).toBe("the real output\n");
|
||||
expect(r.usedSudo).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT throw when command output contains 'incorrect password' but sudo authenticated", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
"__TX_SUDO_OK__\nUser entered an incorrect password earlier",
|
||||
"",
|
||||
0,
|
||||
),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "tail /var/log/auth.log", "pw", {
|
||||
forceSudo: true,
|
||||
});
|
||||
expect(r.usedSudo).toBe(true);
|
||||
expect(r.stdout).toContain("incorrect password");
|
||||
});
|
||||
|
||||
it("throws SUDO_FAILED on a real wrong-password (no marker, sudo stderr)", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("", "sudo: 1 incorrect password attempt", 1),
|
||||
);
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", "wrong", { forceSudo: true }),
|
||||
).rejects.toMatchObject({ code: "SUDO_FAILED" });
|
||||
});
|
||||
|
||||
it("throws NOT_SUDOER when the user is not in sudoers", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("", "deploy is not in the sudoers file.", 1),
|
||||
);
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", "pw", { forceSudo: true }),
|
||||
).rejects.toMatchObject({ code: "NOT_SUDOER" });
|
||||
});
|
||||
|
||||
it("throws SUDO_REQUIRED when forced without a password", async () => {
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", undefined, { forceSudo: true }),
|
||||
).rejects.toBeInstanceOf(ElevationError);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const historyCreate = vi.fn();
|
||||
const historyPrune = vi.fn();
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
getCurrentSettingValue: () => null,
|
||||
createCurrentProxmoxNodeHistoryRepository: () => ({
|
||||
create: historyCreate,
|
||||
pruneOlderThan: historyPrune,
|
||||
}),
|
||||
}));
|
||||
|
||||
const collectProxmoxStats = vi.fn();
|
||||
vi.mock("../../../hosts/metrics/proxmox/collect-proxmox-stats.js", () => ({
|
||||
collectProxmoxStats: (...args: unknown[]) => collectProxmoxStats(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
ProxmoxPollingManager,
|
||||
parseProxmoxStatsConfig,
|
||||
} from "../../../hosts/metrics/proxmox-stats-polling.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
interface TestHost {
|
||||
id: number;
|
||||
userId: string;
|
||||
proxmoxStatsConfig?: string | null;
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
node: {
|
||||
cpu: { percent: 10, cores: 4, load: [0.1, 0.2, 0.3] },
|
||||
memory: { percent: 20, usedGiB: 2, totalGiB: 8 },
|
||||
disk: { percent: 30, usedGiB: 30, totalGiB: 100 },
|
||||
uptime: { seconds: 100, formatted: "0d 0h 1m" },
|
||||
system: { hostname: "pve1", kernel: "6.8", pveVersion: "8.2" },
|
||||
},
|
||||
network: { interfaces: [{ name: "eth0", rxBytes: "10", txBytes: "20" }] },
|
||||
guests: { guests: [], counts: { running: 0, stopped: 0, total: 0 } },
|
||||
storage: { pools: [] },
|
||||
cluster: { clustered: false },
|
||||
lastChecked: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
historyCreate.mockReset();
|
||||
historyPrune.mockReset();
|
||||
collectProxmoxStats.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("parseProxmoxStatsConfig", () => {
|
||||
it("returns default poll interval when config is missing", () => {
|
||||
expect(parseProxmoxStatsConfig(null)).toEqual({
|
||||
nodeName: null,
|
||||
pollInterval: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a JSON string config", () => {
|
||||
expect(
|
||||
parseProxmoxStatsConfig('{"nodeName":"pve1","pollInterval":30}'),
|
||||
).toEqual({ nodeName: "pve1", pollInterval: 30 });
|
||||
});
|
||||
|
||||
it("falls back to defaults on malformed JSON", () => {
|
||||
expect(parseProxmoxStatsConfig("{not json")).toEqual({
|
||||
nodeName: null,
|
||||
pollInterval: 60,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProxmoxPollingManager", () => {
|
||||
function makeManager(host: TestHost) {
|
||||
const fetchHostById = vi.fn(async () => host);
|
||||
const withSshConnection = vi.fn(
|
||||
async (_host: TestHost, fn: (client: Client) => Promise<unknown>) =>
|
||||
fn({} as Client),
|
||||
);
|
||||
const manager = new ProxmoxPollingManager<TestHost>({
|
||||
fetchHostById,
|
||||
withSshConnection,
|
||||
});
|
||||
return { manager, fetchHostById, withSshConnection };
|
||||
}
|
||||
|
||||
it("starts polling and caches a snapshot when the first viewer registers", async () => {
|
||||
const host: TestHost = { id: 1, userId: "user-1" };
|
||||
collectProxmoxStats.mockResolvedValue(snapshot());
|
||||
const { manager, withSshConnection } = makeManager(host);
|
||||
|
||||
manager.registerViewer(1, "viewer-1", "user-1");
|
||||
// registerViewer kicks off polling via a fire-and-forget promise chain.
|
||||
await vi.waitFor(() => {
|
||||
expect(withSshConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const cached = manager.getStats(1);
|
||||
expect(cached?.data.node.cpu.percent).toBe(10);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it("stops polling once the last viewer unregisters", async () => {
|
||||
const host: TestHost = { id: 2, userId: "user-1" };
|
||||
collectProxmoxStats.mockResolvedValue(snapshot());
|
||||
const { manager } = makeManager(host);
|
||||
|
||||
manager.registerViewer(2, "viewer-a", "user-1");
|
||||
manager.registerViewer(2, "viewer-b", "user-1");
|
||||
await vi.waitFor(() => expect(manager.getStats(2)).toBeDefined());
|
||||
|
||||
manager.unregisterViewer(2, "viewer-a");
|
||||
// one viewer left - stats stay cached
|
||||
expect(manager.getStats(2)).toBeDefined();
|
||||
|
||||
manager.unregisterViewer(2, "viewer-b");
|
||||
// Cached snapshot is retained (not cleared) but the interval is stopped;
|
||||
// registering a new viewer must restart polling.
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it("updateHeartbeat returns false for an unknown session", () => {
|
||||
const { manager } = makeManager({ id: 3, userId: "user-1" });
|
||||
expect(manager.updateHeartbeat("nope")).toBe(false);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it("records an error snapshot when collection fails, without throwing", async () => {
|
||||
const host: TestHost = { id: 4, userId: "user-1" };
|
||||
collectProxmoxStats.mockRejectedValue(new Error("pvesh not found"));
|
||||
const { manager } = makeManager(host);
|
||||
|
||||
manager.registerViewer(4, "viewer-1", "user-1");
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getError(4)?.error).toBe("pvesh not found");
|
||||
});
|
||||
expect(manager.getStats(4)).toBeUndefined();
|
||||
manager.destroy();
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
|
||||
import { collectProxmoxClusterHealth } from "../../../../hosts/metrics/proxmox/cluster-health-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxClusterHealth", () => {
|
||||
it("reports clustered:false for a standalone node (no cluster entry)", async () => {
|
||||
execCommand.mockResolvedValueOnce(result(JSON.stringify([]), 0));
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res).toEqual({ clustered: false });
|
||||
});
|
||||
|
||||
it("parses a clustered response with quorum and node entries", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{ type: "cluster", name: "prod-cluster", quorate: 1, nodes: 3 },
|
||||
{ type: "node", name: "pve1", online: 1, local: 1, ip: "10.0.0.1" },
|
||||
{ type: "node", name: "pve2", online: 0, local: 0, ip: "10.0.0.2" },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res.clustered).toBe(true);
|
||||
if (res.clustered) {
|
||||
expect(res.quorate).toBe(true);
|
||||
expect(res.clusterName).toBe("prod-cluster");
|
||||
expect(res.nodes).toHaveLength(2);
|
||||
expect(res.nodes[0]).toEqual({
|
||||
name: "pve1",
|
||||
online: true,
|
||||
local: true,
|
||||
ip: "10.0.0.1",
|
||||
});
|
||||
expect(res.nodes[1].online).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns clustered:false when pvesh fails", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res).toEqual({ clustered: false });
|
||||
});
|
||||
|
||||
it("returns clustered:false on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("not json", 0));
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res).toEqual({ clustered: false });
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxStats } from "../../../../hosts/metrics/proxmox/collect-proxmox-stats.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxStats", () => {
|
||||
it("throws a distinguishable error when pvesh is missing", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("missing"));
|
||||
|
||||
await expect(collectProxmoxStats(fakeClient, null)).rejects.toThrow(
|
||||
/pvesh not found/i,
|
||||
);
|
||||
// Only the pvesh-presence check should have run - no node resolution or
|
||||
// per-collector execs once that check fails.
|
||||
expect(execCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("auto-detects the node name via hostname when none is configured", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
|
||||
execCommand.mockResolvedValueOnce(result("pve-auto\n")); // hostname
|
||||
// Five collectors run concurrently after that; give each a benign failing
|
||||
// response so the aggregator still resolves with null-filled sub-shapes.
|
||||
execCommand.mockResolvedValue(result("", 1));
|
||||
|
||||
const snapshot = await collectProxmoxStats(fakeClient, null);
|
||||
expect(snapshot.lastChecked).toBeTruthy();
|
||||
expect(snapshot.node).toBeDefined();
|
||||
expect(snapshot.guests).toBeDefined();
|
||||
expect(snapshot.storage).toBeDefined();
|
||||
expect(snapshot.cluster).toEqual({ clustered: false });
|
||||
});
|
||||
|
||||
it("uses the configured node name when it is safe, skipping hostname detection", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
|
||||
execCommand.mockResolvedValue(result("", 1)); // all collector calls fail benignly
|
||||
|
||||
await collectProxmoxStats(fakeClient, "my-node");
|
||||
|
||||
// hostname auto-detection command should never have been issued.
|
||||
const calls = execCommand.mock.calls.map((c) => c[1] as string);
|
||||
expect(calls).not.toContain("hostname");
|
||||
});
|
||||
|
||||
it("rejects an unsafe configured node name", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
|
||||
|
||||
await expect(collectProxmoxStats(fakeClient, "bad;node")).rejects.toThrow(
|
||||
/valid Proxmox node name/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxGuestsSummary } from "../../../../hosts/metrics/proxmox/guests-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxGuestsSummary", () => {
|
||||
it("filters to the target node, excludes templates, and computes percentages", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{
|
||||
type: "qemu",
|
||||
node: "pve1",
|
||||
vmid: 100,
|
||||
name: "vm-a",
|
||||
status: "running",
|
||||
cpu: 0.25,
|
||||
mem: 2 * 1024 ** 3,
|
||||
maxmem: 4 * 1024 ** 3,
|
||||
disk: 10 * 1024 ** 3,
|
||||
maxdisk: 40 * 1024 ** 3,
|
||||
uptime: 3600,
|
||||
},
|
||||
{
|
||||
type: "lxc",
|
||||
node: "pve1",
|
||||
vmid: 101,
|
||||
name: "ct-b",
|
||||
status: "stopped",
|
||||
cpu: 0,
|
||||
mem: 0,
|
||||
maxmem: 512 * 1024 ** 2,
|
||||
disk: 0,
|
||||
maxdisk: 8 * 1024 ** 3,
|
||||
uptime: 0,
|
||||
},
|
||||
{
|
||||
// different node - excluded
|
||||
type: "qemu",
|
||||
node: "pve2",
|
||||
vmid: 200,
|
||||
name: "elsewhere",
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
// template - excluded
|
||||
type: "qemu",
|
||||
node: "pve1",
|
||||
vmid: 999,
|
||||
name: "template",
|
||||
status: "stopped",
|
||||
template: 1,
|
||||
},
|
||||
{
|
||||
// non-guest resource type - excluded
|
||||
type: "storage",
|
||||
node: "pve1",
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
|
||||
expect(res.guests).toHaveLength(2);
|
||||
expect(res.counts).toEqual({ running: 1, stopped: 1, total: 2 });
|
||||
|
||||
const vmA = res.guests.find((g) => g.vmid === 100)!;
|
||||
expect(vmA.cpuPercent).toBe(25);
|
||||
expect(vmA.memPercent).toBe(50);
|
||||
expect(vmA.diskPercent).toBe(25);
|
||||
});
|
||||
|
||||
it("reports null disk fields (not a false 0%) when maxdisk is 0", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{
|
||||
type: "qemu",
|
||||
node: "pve1",
|
||||
vmid: 100,
|
||||
name: "vm-no-agent",
|
||||
status: "running",
|
||||
cpu: 0.1,
|
||||
mem: 1024,
|
||||
maxmem: 2048,
|
||||
disk: 0,
|
||||
maxdisk: 0,
|
||||
uptime: 10,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests[0].diskPercent).toBeNull();
|
||||
expect(res.guests[0].diskUsedGiB).toBeNull();
|
||||
expect(res.guests[0].diskTotalGiB).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an empty guest list when there are no matching resources", async () => {
|
||||
execCommand.mockResolvedValueOnce(result(JSON.stringify([]), 0));
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests).toEqual([]);
|
||||
expect(res.counts).toEqual({ running: 0, stopped: 0, total: 0 });
|
||||
});
|
||||
|
||||
it("returns an empty result when pvesh is missing (non-zero exit)", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 127));
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty result on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("{not json", 0));
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "../etc");
|
||||
expect(res.guests).toEqual([]);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
|
||||
import { collectProxmoxNodeNetwork } from "../../../../hosts/metrics/proxmox/node-network-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxNodeNetwork", () => {
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxNodeNetwork(fakeClient, "bad;name");
|
||||
expect(res.interfaces).toEqual([]);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to /proc/net/dev when pvesh netstat fails", async () => {
|
||||
// First call: pvesh netstat -> failure.
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
// Fallback calls: ip addr, ip link, /proc/net/dev.
|
||||
execCommand.mockResolvedValueOnce(result("eth0 10.0.0.5/24\n"));
|
||||
execCommand.mockResolvedValueOnce(result("eth0 UP\n"));
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
"Inter-| Receive\n" +
|
||||
" face |bytes packets\n" +
|
||||
"eth0: 123456 10 0 0 0 0 0 0 654321 20 0 0 0 0 0 0\n",
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxNodeNetwork(fakeClient, "pve1");
|
||||
expect(res.interfaces).toHaveLength(1);
|
||||
expect(res.interfaces[0]).toMatchObject({
|
||||
name: "eth0",
|
||||
ip: "10.0.0.5",
|
||||
state: "UP",
|
||||
rxBytes: "123456",
|
||||
txBytes: "654321",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to /proc/net/dev when pvesh returns unparseable data", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("not json", 0));
|
||||
execCommand.mockResolvedValueOnce(result(""));
|
||||
execCommand.mockResolvedValueOnce(result(""));
|
||||
execCommand.mockResolvedValueOnce(result("Inter-| Receive\n face |\n"));
|
||||
|
||||
const res = await collectProxmoxNodeNetwork(fakeClient, "pve1");
|
||||
expect(res.interfaces).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxNodeStatus } from "../../../../hosts/metrics/proxmox/node-status-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxNodeStatus", () => {
|
||||
it("parses a healthy pvesh /nodes/{node}/status response", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify({
|
||||
cpu: 0.15,
|
||||
cpuinfo: { cores: 8 },
|
||||
loadavg: ["0.5", "0.6", "0.7"],
|
||||
memory: { used: 4 * 1024 ** 3, total: 16 * 1024 ** 3 },
|
||||
rootfs: { used: 20 * 1024 ** 3, total: 100 * 1024 ** 3 },
|
||||
uptime: 90061,
|
||||
hostname: "pve1",
|
||||
kversion: "Linux 6.8.0",
|
||||
pveversion: "pve-manager/8.2.0",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
|
||||
|
||||
expect(res.cpu.percent).toBe(15);
|
||||
expect(res.cpu.cores).toBe(8);
|
||||
expect(res.cpu.load).toEqual([0.5, 0.6, 0.7]);
|
||||
expect(res.memory.percent).toBe(25);
|
||||
expect(res.memory.usedGiB).toBeCloseTo(4, 1);
|
||||
expect(res.memory.totalGiB).toBeCloseTo(16, 1);
|
||||
expect(res.disk.percent).toBe(20);
|
||||
expect(res.uptime.seconds).toBe(90061);
|
||||
expect(res.uptime.formatted).toBe("1d 1h 1m");
|
||||
expect(res.system.hostname).toBe("pve1");
|
||||
expect(res.system.kernel).toBe("Linux 6.8.0");
|
||||
expect(res.system.pveVersion).toBe("pve-manager/8.2.0");
|
||||
});
|
||||
|
||||
it("returns a fully null-filled shape when pvesh exits non-zero", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
|
||||
expect(res.cpu.percent).toBeNull();
|
||||
expect(res.memory.percent).toBeNull();
|
||||
expect(res.disk.percent).toBeNull();
|
||||
expect(res.uptime.seconds).toBeNull();
|
||||
expect(res.system.hostname).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null-filled shape on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("not json", 0));
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
|
||||
expect(res.cpu.percent).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1; rm -rf /");
|
||||
expect(res.cpu.percent).toBeNull();
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxStorage } from "../../../../hosts/metrics/proxmox/storage-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxStorage", () => {
|
||||
it("parses storage pools with usage percentages", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{
|
||||
storage: "local",
|
||||
type: "dir",
|
||||
active: 1,
|
||||
enabled: 1,
|
||||
used: 20 * 1024 ** 3,
|
||||
total: 100 * 1024 ** 3,
|
||||
avail: 80 * 1024 ** 3,
|
||||
},
|
||||
{
|
||||
storage: "local-zfs",
|
||||
type: "zfspool",
|
||||
active: 0,
|
||||
enabled: 1,
|
||||
used: 0,
|
||||
total: 0,
|
||||
avail: 0,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxStorage(fakeClient, "pve1");
|
||||
expect(res.pools).toHaveLength(2);
|
||||
expect(res.pools[0].name).toBe("local");
|
||||
expect(res.pools[0].active).toBe(true);
|
||||
expect(res.pools[0].percent).toBe(20);
|
||||
expect(res.pools[1].active).toBe(false);
|
||||
expect(res.pools[1].percent).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an empty pool list when pvesh fails", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
const res = await collectProxmoxStorage(fakeClient, "pve1");
|
||||
expect(res.pools).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty pool list on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("nope", 0));
|
||||
const res = await collectProxmoxStorage(fakeClient, "pve1");
|
||||
expect(res.pools).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxStorage(fakeClient, "$(whoami)");
|
||||
expect(res.pools).toEqual([]);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// Regression guard for Support#1216: routine status polling on a host with
|
||||
// metrics disabled ran a full SSH authentication attempt on every interval,
|
||||
// which fires a live 2FA push each cycle on RADIUS/Duo-backed devices with
|
||||
// no active session. Spinning up the full metrics-service Express app (DB,
|
||||
// SSH clients, timers, polling managers, etc.) just to drive pollHostStatus
|
||||
// through two intervals is out of scope, so this asserts directly against
|
||||
// the source that the status-poll path never calls into SSH auth, the same
|
||||
// way login-alert-route-order.test.ts guards route registration order.
|
||||
describe("metrics service status polling", () => {
|
||||
it("never authenticates over SSH from pollHostStatus, on the first poll or any later one", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../../hosts/metrics/index.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const pollHostStatusStart = source.indexOf("private async pollHostStatus(");
|
||||
const pollHostMetricsStart = source.indexOf(
|
||||
"private async pollHostMetrics(",
|
||||
);
|
||||
|
||||
expect(pollHostStatusStart).toBeGreaterThan(-1);
|
||||
expect(pollHostMetricsStart).toBeGreaterThan(pollHostStatusStart);
|
||||
|
||||
const pollHostStatusBody = source.slice(
|
||||
pollHostStatusStart,
|
||||
pollHostMetricsStart,
|
||||
);
|
||||
|
||||
expect(pollHostStatusBody).not.toContain("withSshConnection");
|
||||
expect(pollHostStatusBody).not.toContain("statusAfterAuthentication");
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toFixedNum,
|
||||
kibToGiB,
|
||||
encodePowerShellCommand,
|
||||
} from "../../../../hosts/metrics/widgets/common-utils.js";
|
||||
|
||||
describe("toFixedNum", () => {
|
||||
it("rounds to the requested digit count", () => {
|
||||
expect(toFixedNum(3.14159, 2)).toBe(3.14);
|
||||
expect(toFixedNum(3.14159, 0)).toBe(3);
|
||||
expect(toFixedNum(2.5, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it("defaults to 2 digits", () => {
|
||||
expect(toFixedNum(1.23456)).toBe(1.23);
|
||||
});
|
||||
|
||||
it("returns null for non-finite or non-number input", () => {
|
||||
expect(toFixedNum(null)).toBeNull();
|
||||
expect(toFixedNum(undefined)).toBeNull();
|
||||
expect(toFixedNum(NaN)).toBeNull();
|
||||
expect(toFixedNum(Infinity)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("kibToGiB", () => {
|
||||
it("converts kibibytes to gibibytes", () => {
|
||||
expect(kibToGiB(1024 * 1024)).toBe(1);
|
||||
expect(kibToGiB(0)).toBe(0);
|
||||
expect(kibToGiB(2 * 1024 * 1024)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodePowerShellCommand", () => {
|
||||
it("UTF-16LE base64 encodes the script, decodable back to the source", () => {
|
||||
const script = "Write-Output 'hello'";
|
||||
const encoded = encodePowerShellCommand(script);
|
||||
expect(Buffer.from(encoded, "base64").toString("utf16le")).toBe(script);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseCpuLine,
|
||||
parseDarwinCpuUsageLine,
|
||||
} from "../../../../hosts/metrics/widgets/cpu-collector.js";
|
||||
|
||||
describe("parseCpuLine", () => {
|
||||
it("parses a standard /proc/stat cpu line", () => {
|
||||
// user nice system idle iowait irq softirq
|
||||
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
|
||||
expect(result).toBeDefined();
|
||||
// idle = idle(800) + iowait(30)
|
||||
expect(result?.idle).toBe(830);
|
||||
// total = sum of all fields
|
||||
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
|
||||
});
|
||||
|
||||
it("tolerates leading/trailing whitespace", () => {
|
||||
const result = parseCpuLine(" cpu 1 2 3 4 ");
|
||||
expect(result?.total).toBe(10);
|
||||
expect(result?.idle).toBe(4);
|
||||
});
|
||||
|
||||
it("returns undefined for non-cpu lines", () => {
|
||||
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
|
||||
expect(parseCpuLine("intr 12345")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when there are fewer than 4 numeric fields", () => {
|
||||
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDarwinCpuUsageLine", () => {
|
||||
it("parses macOS `top -l 1` CPU usage line", () => {
|
||||
const result = parseDarwinCpuUsageLine(
|
||||
"CPU usage: 12.34% user, 5.66% sys, 82.00% idle",
|
||||
);
|
||||
expect(result?.percent).toBeCloseTo(18.0, 5);
|
||||
});
|
||||
|
||||
it("returns undefined for unrelated lines", () => {
|
||||
expect(parseDarwinCpuUsageLine("Processes: 400 total")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,319 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseDfLines,
|
||||
findWorstMountIndex,
|
||||
buildFilesystemList,
|
||||
selectPrimaryFilesystem,
|
||||
filterExcludedFilesystems,
|
||||
mergeMonitoredFilesystems,
|
||||
parseWindowsDiskJson,
|
||||
parseDarwinMountTypes,
|
||||
parseDarwinDfRows,
|
||||
} from "../../../../hosts/metrics/widgets/disk-collector.js";
|
||||
|
||||
describe("parseDfLines", () => {
|
||||
it("parses df -T -P output into rows", () => {
|
||||
const output =
|
||||
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].mount).toBe("/");
|
||||
expect(rows[0].type).toBe("ext4");
|
||||
expect(rows[1].mount).toBe("/data");
|
||||
});
|
||||
|
||||
it("filters out pseudo filesystems", () => {
|
||||
const output =
|
||||
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
|
||||
"overlay overlay 100 50 50 50% /\n" +
|
||||
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].mount).toBe("/mnt/data");
|
||||
});
|
||||
|
||||
it("captures the filesystem type for network shares", () => {
|
||||
const output =
|
||||
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows[0].type).toBe("nfs4");
|
||||
expect(rows[1].type).toBe("cifs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findWorstMountIndex", () => {
|
||||
it("picks the most-utilized mount, not just the first row", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
|
||||
);
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(1);
|
||||
expect(worst.totalBytes).toBe(15393162788864);
|
||||
expect(worst.usedBytes).toBe(15239230844928);
|
||||
});
|
||||
|
||||
it("falls back to the only mount available", () => {
|
||||
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(0);
|
||||
});
|
||||
|
||||
it("skips rows with invalid or zero totals", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
|
||||
"/dev/sda2 ext4 100 40 60 40% /ok\n",
|
||||
);
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(1);
|
||||
});
|
||||
|
||||
it("returns index -1 when there are no usable rows", () => {
|
||||
const worst = findWorstMountIndex([]);
|
||||
expect(worst.index).toBe(-1);
|
||||
expect(worst.totalBytes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const BYTES_OUTPUT =
|
||||
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
|
||||
const HUMAN_OUTPUT =
|
||||
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
|
||||
|
||||
describe("buildFilesystemList", () => {
|
||||
it("returns every real filesystem with byte maths and human strings", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines(HUMAN_OUTPUT),
|
||||
);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]).toMatchObject({
|
||||
mount: "/",
|
||||
type: "ext4",
|
||||
percent: 40,
|
||||
usedHuman: "400",
|
||||
totalHuman: "1.0K",
|
||||
availableHuman: "600",
|
||||
usedBytes: 400,
|
||||
totalBytes: 1000,
|
||||
});
|
||||
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
|
||||
});
|
||||
|
||||
it("matches human rows by mount when the row counts differ", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
|
||||
);
|
||||
expect(list[0].totalHuman).toBeNull();
|
||||
expect(list[1].totalHuman).toBe("2.0K");
|
||||
});
|
||||
|
||||
it("drops filesystems with a zero or invalid total", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].mount).toBe("/ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectPrimaryFilesystem", () => {
|
||||
it("prefers root over a fuller secondary mount", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines(HUMAN_OUTPUT),
|
||||
);
|
||||
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
|
||||
});
|
||||
|
||||
it("falls back to the most-utilized mount when there is no root", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
|
||||
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
|
||||
});
|
||||
|
||||
it("returns null for an empty list", () => {
|
||||
expect(selectPrimaryFilesystem([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterExcludedFilesystems", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 ext4 1000 400 600 40% /\n" +
|
||||
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
it("returns the same list when no mounts are excluded", () => {
|
||||
expect(filterExcludedFilesystems(list)).toHaveLength(3);
|
||||
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("excludes an exact mount path match", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
|
||||
});
|
||||
|
||||
it("excludes by filesystem type substring, case-insensitively", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["NFS"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
|
||||
});
|
||||
|
||||
it("supports excluding multiple network filesystem types at once", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
|
||||
});
|
||||
|
||||
it("ignores blank/whitespace-only entries", () => {
|
||||
const filtered = filterExcludedFilesystems(list, [" ", ""]);
|
||||
expect(filtered).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeMonitoredFilesystems", () => {
|
||||
it("adds an arbitrary path with a user label", () => {
|
||||
const detected = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
|
||||
[],
|
||||
);
|
||||
const custom = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
|
||||
[],
|
||||
);
|
||||
const result = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
[{ path: "/config", label: "Home Assistant" }],
|
||||
custom,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1]).toMatchObject({
|
||||
mount: "/config",
|
||||
label: "Home Assistant",
|
||||
totalBytes: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("labels a path that is already a detected mount", () => {
|
||||
const detected = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
|
||||
[],
|
||||
);
|
||||
const result = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
[{ path: "/data", label: "Media" }],
|
||||
detected,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].label).toBe("Media");
|
||||
});
|
||||
});
|
||||
|
||||
// Representative output shape from `df -Pk` and `mount` on a modern macOS
|
||||
// (Sonoma/Sequoia, Apple Silicon) system, where the APFS root volume group
|
||||
// surfaces as several synthetic system volumes alongside the real "/" and
|
||||
// "/System/Volumes/Data" mounts.
|
||||
const DARWIN_DF_OUTPUT =
|
||||
"Filesystem 1024-blocks Used Available Capacity Mounted on\n" +
|
||||
"/dev/disk3s1s1 971350180 10904432 723545416 2% /\n" +
|
||||
"devfs 205 205 0 100% /dev\n" +
|
||||
"/dev/disk3s6 971350180 8437248 723545416 2% /System/Volumes/VM\n" +
|
||||
"/dev/disk3s2 971350180 6067864 723545416 1% /System/Volumes/Preboot\n" +
|
||||
"/dev/disk3s4 971350180 16612 723545416 1% /System/Volumes/Update\n" +
|
||||
"/dev/disk1s2 500098 6104 479950 2% /System/Volumes/xarts\n" +
|
||||
"/dev/disk1s1 500098 5388 479950 2% /System/Volumes/iSCPreboot\n" +
|
||||
"/dev/disk1s3 500098 36 479950 1% /System/Volumes/Hardware\n" +
|
||||
"/dev/disk3s5 971350180 235925064 723545416 25% /System/Volumes/Data\n" +
|
||||
"map auto_home 0 0 0 100% /System/Volumes/Data/home\n";
|
||||
|
||||
const DARWIN_MOUNT_OUTPUT =
|
||||
"/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)\n" +
|
||||
"devfs on /dev (devfs, local, nobrowse)\n" +
|
||||
"/dev/disk3s6 on /System/Volumes/VM (apfs, local, noexec, journaled, noatime, nobrowse)\n" +
|
||||
"/dev/disk3s2 on /System/Volumes/Preboot (apfs, local, journaled, nobrowse)\n" +
|
||||
"/dev/disk3s4 on /System/Volumes/Update (apfs, local, journaled, nobrowse)\n" +
|
||||
"/dev/disk1s2 on /System/Volumes/xarts (apfs, local, noexec, journaled, noatime, nobrowse)\n" +
|
||||
"/dev/disk1s1 on /System/Volumes/iSCPreboot (apfs, local, journaled, nobrowse)\n" +
|
||||
"/dev/disk1s3 on /System/Volumes/Hardware (apfs, local, journaled, nobrowse)\n" +
|
||||
"/dev/disk3s5 on /System/Volumes/Data (apfs, local, journaled, nobrowse)\n" +
|
||||
"map auto_home on /System/Volumes/Data/home (autofs, automounted, nobrowse)\n";
|
||||
|
||||
describe("parseDarwinMountTypes", () => {
|
||||
it("maps each mount point to its filesystem type", () => {
|
||||
const map = parseDarwinMountTypes(DARWIN_MOUNT_OUTPUT);
|
||||
expect(map.get("/")).toBe("apfs");
|
||||
expect(map.get("/dev")).toBe("devfs");
|
||||
expect(map.get("/System/Volumes/Data/home")).toBe("autofs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDarwinDfRows", () => {
|
||||
it("parses real `df -Pk` rows and drops devfs/autofs and synthetic system volumes", () => {
|
||||
const typeByMount = parseDarwinMountTypes(DARWIN_MOUNT_OUTPUT);
|
||||
const rows = parseDarwinDfRows(DARWIN_DF_OUTPUT, typeByMount);
|
||||
|
||||
expect(rows.map((r) => r.mount)).toEqual(["/", "/System/Volumes/Data"]);
|
||||
|
||||
const root = rows[0];
|
||||
expect(root.type).toBe("apfs");
|
||||
expect(root.totalBytes).toBe(971350180 * 1024);
|
||||
expect(root.usedBytes).toBe(10904432 * 1024);
|
||||
expect(root.availableBytes).toBe(723545416 * 1024);
|
||||
// Our percent is used/total (matches the Linux/Windows collectors), which
|
||||
// differs slightly from df's own Capacity% (used/(used+available)).
|
||||
expect(root.percent).toBe(1);
|
||||
expect(root.usedHuman).toBeTruthy();
|
||||
expect(root.totalHuman).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns an empty list for blank output", () => {
|
||||
expect(parseDarwinDfRows("", new Map())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWindowsDiskJson", () => {
|
||||
it("handles ConvertTo-Json collapsing a single result to an object", () => {
|
||||
const rows = parseWindowsDiskJson(
|
||||
'{"drive":"C:","total":1000000000,"free":400000000}',
|
||||
);
|
||||
expect(rows).toEqual([{ drive: "C:", total: 1000000000, free: 400000000 }]);
|
||||
});
|
||||
|
||||
it("parses an array of drives", () => {
|
||||
const rows = parseWindowsDiskJson(
|
||||
'[{"drive":"C:","total":1000,"free":400},{"drive":"D:","total":2000,"free":1900}]',
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[1]).toEqual({ drive: "D:", total: 2000, free: 1900 });
|
||||
});
|
||||
|
||||
it("drops drives with a zero or missing total", () => {
|
||||
const rows = parseWindowsDiskJson(
|
||||
'[{"drive":"C:","total":0,"free":0},{"drive":"D:","total":100,"free":50}]',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].drive).toBe("D:");
|
||||
});
|
||||
|
||||
it("returns an empty array for blank or invalid output", () => {
|
||||
expect(parseWindowsDiskJson("")).toEqual([]);
|
||||
expect(parseWindowsDiskJson("not json")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseNvidiaSmiGpus,
|
||||
parseNvidiaSmiOutput,
|
||||
parseNvidiaSmiProcesses,
|
||||
} from "../../../../hosts/metrics/widgets/gpu-collector.js";
|
||||
|
||||
const RTX_UUID = "GPU-3b8f2c1a-5d4e-7f60-8a9b-0c1d2e3f4a5b";
|
||||
const H100_UUID = "GPU-9a8b7c6d-5e4f-3a2b-1c0d-e9f8a7b6c5d4";
|
||||
|
||||
// index, uuid, driver_version, utilization.gpu, memory.used, memory.total,
|
||||
// temperature.gpu, power.draw, power.limit, fan.speed, name
|
||||
const GPU_QUERY_OUTPUT = [
|
||||
`0, ${RTX_UUID}, 550.54.14, 37, 8123, 24564, 61, 182.47, 450.00, 45, NVIDIA GeForce RTX 4090`,
|
||||
`1, ${H100_UUID}, 550.54.14, 0, 1, 81559, 34, 62.10, 700.00, [N/A], NVIDIA H100 80GB HBM3`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
// gpu_uuid, pid, used_memory, process_name
|
||||
const APPS_QUERY_OUTPUT = [
|
||||
`${RTX_UUID}, 12345, 7890, /usr/bin/python3`,
|
||||
`${H100_UUID}, 23456, [N/A], /opt/app/worker, gpu, v2`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const DRIVER_FAILURE =
|
||||
"NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver. Make sure that the latest NVIDIA driver is installed and running.\n";
|
||||
|
||||
const RTX = {
|
||||
index: 0,
|
||||
uuid: RTX_UUID,
|
||||
name: "NVIDIA GeForce RTX 4090",
|
||||
driverVersion: "550.54.14",
|
||||
utilizationPercent: 37,
|
||||
memoryUsedMiB: 8123,
|
||||
memoryTotalMiB: 24564,
|
||||
memoryPercent: 33.1,
|
||||
temperatureCelsius: 61,
|
||||
powerDrawWatts: 182.5,
|
||||
powerLimitWatts: 450,
|
||||
fanPercent: 45,
|
||||
};
|
||||
|
||||
const H100 = {
|
||||
index: 1,
|
||||
uuid: H100_UUID,
|
||||
name: "NVIDIA H100 80GB HBM3",
|
||||
driverVersion: "550.54.14",
|
||||
utilizationPercent: 0,
|
||||
memoryUsedMiB: 1,
|
||||
memoryTotalMiB: 81559,
|
||||
memoryPercent: 0,
|
||||
temperatureCelsius: 34,
|
||||
powerDrawWatts: 62.1,
|
||||
powerLimitWatts: 700,
|
||||
fanPercent: null,
|
||||
};
|
||||
|
||||
describe("gpu collector", () => {
|
||||
describe("parseNvidiaSmiGpus", () => {
|
||||
it("parses one device per row of --query-gpu output", () => {
|
||||
expect(parseNvidiaSmiGpus(GPU_QUERY_OUTPUT)).toEqual([RTX, H100]);
|
||||
});
|
||||
|
||||
it("maps unsupported and unavailable readings to null", () => {
|
||||
const [gpu] = parseNvidiaSmiGpus(
|
||||
"0, GPU-aaaa, 470.182.03, [N/A], 512, [N/A], [Not Supported], [Not Supported], [N/A], [Unknown Error], Tesla K80\n",
|
||||
);
|
||||
|
||||
expect(gpu).toEqual({
|
||||
index: 0,
|
||||
uuid: "GPU-aaaa",
|
||||
name: "Tesla K80",
|
||||
driverVersion: "470.182.03",
|
||||
utilizationPercent: null,
|
||||
memoryUsedMiB: 512,
|
||||
memoryTotalMiB: null,
|
||||
memoryPercent: null,
|
||||
temperatureCelsius: null,
|
||||
powerDrawWatts: null,
|
||||
powerLimitWatts: null,
|
||||
fanPercent: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a device name that contains commas intact", () => {
|
||||
const [gpu] = parseNvidiaSmiGpus(
|
||||
"0, GPU-bbbb, 535.0, 5, 10, 100, 40, 20, 70, 30, Quadro RTX, Mobile, Max-Q\n",
|
||||
);
|
||||
|
||||
expect(gpu.name).toBe("Quadro RTX, Mobile, Max-Q");
|
||||
expect(gpu.fanPercent).toBe(30);
|
||||
});
|
||||
|
||||
it("ignores driver error text printed to stdout", () => {
|
||||
expect(parseNvidiaSmiGpus(DRIVER_FAILURE)).toEqual([]);
|
||||
expect(parseNvidiaSmiGpus("No devices were found\n")).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a csv header row", () => {
|
||||
const header =
|
||||
"index, uuid, driver_version, utilization.gpu [%], memory.used [MiB], memory.total [MiB], temperature.gpu, power.draw [W], power.limit [W], fan.speed [%], name\n";
|
||||
|
||||
expect(parseNvidiaSmiGpus(header + GPU_QUERY_OUTPUT)).toEqual([
|
||||
RTX,
|
||||
H100,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNvidiaSmiProcesses", () => {
|
||||
it("attaches each process to the GPU with the matching uuid", () => {
|
||||
expect(parseNvidiaSmiProcesses(APPS_QUERY_OUTPUT, [RTX, H100])).toEqual([
|
||||
{
|
||||
gpuIndex: 0,
|
||||
pid: 12345,
|
||||
name: "/usr/bin/python3",
|
||||
memoryUsedMiB: 7890,
|
||||
},
|
||||
{
|
||||
gpuIndex: 1,
|
||||
pid: 23456,
|
||||
name: "/opt/app/worker, gpu, v2",
|
||||
memoryUsedMiB: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps processes whose uuid matches no listed GPU", () => {
|
||||
expect(
|
||||
parseNvidiaSmiProcesses("GPU-unknown, 77, 100, /bin/app\n", [RTX]),
|
||||
).toEqual([
|
||||
{ gpuIndex: null, pid: 77, name: "/bin/app", memoryUsedMiB: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores driver error text printed to stdout", () => {
|
||||
expect(parseNvidiaSmiProcesses(DRIVER_FAILURE, [RTX])).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a csv header row", () => {
|
||||
expect(
|
||||
parseNvidiaSmiProcesses(
|
||||
"gpu_uuid, pid, used_gpu_memory [MiB], process_name\n",
|
||||
[RTX],
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNvidiaSmiOutput", () => {
|
||||
it("splits the combined command output into devices and processes", () => {
|
||||
const result = parseNvidiaSmiOutput(
|
||||
`${GPU_QUERY_OUTPUT}---\n${APPS_QUERY_OUTPUT}`,
|
||||
);
|
||||
|
||||
expect(result.source).toBe("nvidia-smi");
|
||||
expect(result.gpus).toEqual([RTX, H100]);
|
||||
expect(result.processes.map((p) => [p.gpuIndex, p.pid])).toEqual([
|
||||
[0, 12345],
|
||||
[1, 23456],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports no source when nvidia-smi is not installed", () => {
|
||||
expect(parseNvidiaSmiOutput("")).toEqual({
|
||||
source: "none",
|
||||
gpus: [],
|
||||
processes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no source when the driver cannot be reached", () => {
|
||||
expect(
|
||||
parseNvidiaSmiOutput(`${DRIVER_FAILURE}---\n${DRIVER_FAILURE}`),
|
||||
).toEqual({ source: "none", gpus: [], processes: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
counterRate,
|
||||
parseNetworkCounters,
|
||||
parseDarwinIfconfig,
|
||||
parseDarwinNetstat,
|
||||
parseWindowsAdapterJson,
|
||||
} from "../../../../hosts/metrics/widgets/network-collector.js";
|
||||
|
||||
const PROC_NET = `Inter-| Receive | Transmit
|
||||
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
||||
eth0: 1024 1 0 0 0 0 0 0 2048 2 0 0 0 0 0 0
|
||||
lo: 4096 4 0 0 0 0 0 0 4096 4 0 0 0 0 0 0`;
|
||||
|
||||
describe("network counters", () => {
|
||||
it("parses Linux proc counters", () => {
|
||||
expect(parseNetworkCounters(PROC_NET).get("eth0")).toEqual({
|
||||
rx: "1024",
|
||||
tx: "2048",
|
||||
});
|
||||
});
|
||||
|
||||
it("calculates bytes per second and rejects counter resets", () => {
|
||||
expect(counterRate("1000", "2500", 0.5)).toBe(3000);
|
||||
expect(counterRate("2500", "1000", 0.5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const DARWIN_IFCONFIG = `lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
|
||||
inet 127.0.0.1 netmask 0xff000000
|
||||
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
|
||||
ether ac:de:48:00:11:22
|
||||
inet 192.168.1.5 netmask 0xffffff00 broadcast 192.168.1.255
|
||||
status: active
|
||||
en1: flags=8822<BROADCAST,SMART,SIMPLEX,MULTICAST> mtu 1500
|
||||
ether ac:de:48:00:11:23
|
||||
status: inactive`;
|
||||
|
||||
const DARWIN_NETSTAT = `Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll
|
||||
lo0 16384 <Link#1> 466472 0 62511610 466472 0 62511610 0
|
||||
lo0 16384 127 127.0.0.1 466472 - 62511610 466472 - 62511610 -
|
||||
en0 1500 <Link#4> ac:de:48:00:11:22 123456 0 987654321 65432 0 123456789 0
|
||||
en0 1500 192.168.1 192.168.1.5 123456 - 987654321 65432 - 123456789 -`;
|
||||
|
||||
describe("darwin network parsing", () => {
|
||||
it("parses interface addresses and up/down state from ifconfig", () => {
|
||||
const map = parseDarwinIfconfig(DARWIN_IFCONFIG);
|
||||
expect(map.has("lo0")).toBe(false);
|
||||
expect(map.get("en0")).toEqual({ ip: "192.168.1.5", state: "UP" });
|
||||
expect(map.get("en1")).toEqual({ ip: "", state: "DOWN" });
|
||||
});
|
||||
|
||||
it("parses byte counters from the Link-layer netstat -ib row", () => {
|
||||
const counters = parseDarwinNetstat(DARWIN_NETSTAT);
|
||||
expect(counters.has("lo0")).toBe(false);
|
||||
expect(counters.get("en0")).toEqual({
|
||||
rx: "987654321",
|
||||
tx: "123456789",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWindowsAdapterJson", () => {
|
||||
it("handles ConvertTo-Json collapsing a single result to an object", () => {
|
||||
const rows = parseWindowsAdapterJson(
|
||||
'{"name":"Ethernet","ip":"10.0.0.5","state":"UP","rx":1000,"tx":2000}',
|
||||
);
|
||||
expect(rows).toEqual([
|
||||
{ name: "Ethernet", ip: "10.0.0.5", state: "UP", rx: "1000", tx: "2000" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses an array of adapters", () => {
|
||||
const rows = parseWindowsAdapterJson(
|
||||
'[{"name":"Ethernet","ip":"10.0.0.5","state":"UP","rx":1000,"tx":2000},' +
|
||||
'{"name":"Wi-Fi","ip":null,"state":"UP","rx":500,"tx":700}]',
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[1]).toEqual({
|
||||
name: "Wi-Fi",
|
||||
ip: "",
|
||||
state: "UP",
|
||||
rx: "500",
|
||||
tx: "700",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty array for blank or invalid output", () => {
|
||||
expect(parseWindowsAdapterJson("")).toEqual([]);
|
||||
expect(parseWindowsAdapterJson("not json")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseSensorsOutput,
|
||||
parseSysfsThermalOutput,
|
||||
} from "../../../../hosts/metrics/widgets/temperature-collector.js";
|
||||
|
||||
describe("temperature collectors", () => {
|
||||
it("parses sysfs thermal zone output", () => {
|
||||
const result = parseSysfsThermalOutput(
|
||||
"x86_pkg_temp\t51000\nacpitz\t42\nbad\tnope\n",
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ label: "x86_pkg_temp", celsius: 51 },
|
||||
{ label: "acpitz", celsius: 42 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses lm-sensors temperature lines", () => {
|
||||
const result = parseSensorsOutput(`
|
||||
coretemp-isa-0000
|
||||
Adapter: ISA adapter
|
||||
Package id 0: +52.0°C (high = +80.0°C, crit = +100.0°C)
|
||||
Core 0: +48.5°C
|
||||
fan1: 1200 RPM
|
||||
`);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ label: "coretemp-isa-0000: Package id 0", celsius: 52 },
|
||||
{ label: "coretemp-isa-0000: Core 0", celsius: 48.5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps duplicate sensor names distinct across devices", () => {
|
||||
const result = parseSensorsOutput(`
|
||||
nvme-pci-0100
|
||||
Composite: +41.0°C
|
||||
nvme-pci-0200
|
||||
Composite: +52.0°C
|
||||
`);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ label: "nvme-pci-0100: Composite", celsius: 41 },
|
||||
{ label: "nvme-pci-0200: Composite", celsius: 52 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => null,
|
||||
getSqlite: () => null,
|
||||
}));
|
||||
vi.mock("../../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async (id: number, userId: string) =>
|
||||
id === 5 && userId === "importer" ? { id, username: "u" } : null,
|
||||
}));
|
||||
|
||||
import {
|
||||
fromPortableLogin,
|
||||
keepUsableProtocolCredentials,
|
||||
mergeProtocolLogin,
|
||||
readProtocolAuthPayload,
|
||||
toPortableLogins,
|
||||
} from "../../../hosts/protocol-auth/protocol-auth.js";
|
||||
import {
|
||||
setHostProtocolSource,
|
||||
type DeclaredHostProtocol,
|
||||
} from "../../../hosts/protocol-auth/registry.js";
|
||||
import type { HostProtocolLogin } from "../../../database/repositories/host-protocol-auth-repository.js";
|
||||
|
||||
const SPICE: DeclaredHostProtocol = {
|
||||
id: "spice",
|
||||
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
|
||||
pluginId: "spice-plugin",
|
||||
pluginName: "Spice",
|
||||
};
|
||||
const REMOTE_X: DeclaredHostProtocol = {
|
||||
id: "remote-x",
|
||||
credentialFields: [{ key: "domain" }],
|
||||
pluginId: "x-plugin",
|
||||
pluginName: "X",
|
||||
};
|
||||
|
||||
const stored: HostProtocolLogin = {
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
password: "saved",
|
||||
fields: { display: "2" },
|
||||
secretFields: { ticket: "t-saved" },
|
||||
};
|
||||
|
||||
beforeEach(() => setHostProtocolSource(() => [SPICE, REMOTE_X]));
|
||||
afterEach(() => setHostProtocolSource(() => []));
|
||||
|
||||
describe("readProtocolAuthPayload", () => {
|
||||
it("reads protocolAuth for declared protocols only", () => {
|
||||
const patch = readProtocolAuthPayload({
|
||||
protocolAuth: {
|
||||
spice: { username: "a" },
|
||||
"remote-x": null,
|
||||
stranger: { username: "b" },
|
||||
},
|
||||
});
|
||||
expect(patch && Object.fromEntries(patch)).toEqual({
|
||||
spice: { username: "a" },
|
||||
"remote-x": null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the flat fields a 2.8 client sends, named after the protocol", () => {
|
||||
const patch = readProtocolAuthPayload({
|
||||
spiceUser: "viewer",
|
||||
spicePassword: "pw",
|
||||
spiceDisplay: "3",
|
||||
spiceAuthType: "direct",
|
||||
remoteXUser: "x-user",
|
||||
remoteXDomain: "CORP",
|
||||
remoteXCredentialId: 9,
|
||||
});
|
||||
expect(patch && Object.fromEntries(patch)).toEqual({
|
||||
spice: {
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
password: "pw",
|
||||
fields: { display: "3" },
|
||||
},
|
||||
"remote-x": {
|
||||
credentialId: 9,
|
||||
username: "x-user",
|
||||
fields: { domain: "CORP" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a 2.8 editor's null password as leaving it alone", () => {
|
||||
const patch = readProtocolAuthPayload({
|
||||
spiceAuthType: "direct",
|
||||
spiceUser: "viewer",
|
||||
spicePassword: null,
|
||||
});
|
||||
expect(patch?.get("spice")).toEqual({
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a 2.8 editor's all-null protocol as removed", () => {
|
||||
const patch = readProtocolAuthPayload({
|
||||
spiceAuthType: null,
|
||||
spiceUser: null,
|
||||
spicePassword: null,
|
||||
spiceCredentialId: null,
|
||||
});
|
||||
expect(patch?.get("spice")).toBeNull();
|
||||
});
|
||||
|
||||
it("answers null when a write carries no logins", () => {
|
||||
expect(readProtocolAuthPayload({ name: "host" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeProtocolLogin", () => {
|
||||
it("keeps what the input leaves out, including secrets", () => {
|
||||
expect(mergeProtocolLogin("spice", SPICE, stored, {})).toEqual(stored);
|
||||
});
|
||||
|
||||
it("keeps a saved password and secret for an empty value", () => {
|
||||
const next = mergeProtocolLogin("spice", SPICE, stored, {
|
||||
password: "",
|
||||
fields: { ticket: "", display: "" },
|
||||
});
|
||||
expect(next.password).toBe("saved");
|
||||
expect(next.secretFields).toEqual({ ticket: "t-saved" });
|
||||
// A plain field can be cleared.
|
||||
expect(next.fields).toEqual({});
|
||||
});
|
||||
|
||||
it("drops the direct login and undeclared fields for a credential", () => {
|
||||
const next = mergeProtocolLogin("spice", SPICE, stored, {
|
||||
authType: "credential",
|
||||
credentialId: "12",
|
||||
fields: { unknown: "x" },
|
||||
});
|
||||
expect(next).toMatchObject({
|
||||
authType: "credential",
|
||||
credentialId: 12,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: { display: "2" },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a new login from the input", () => {
|
||||
expect(
|
||||
mergeProtocolLogin("spice", SPICE, null, {
|
||||
username: "new",
|
||||
password: "pw",
|
||||
fields: { ticket: "t" },
|
||||
}),
|
||||
).toEqual({
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "new",
|
||||
password: "pw",
|
||||
fields: {},
|
||||
secretFields: { ticket: "t" },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to direct for an auth type it does not know", () => {
|
||||
expect(
|
||||
mergeProtocolLogin("spice", SPICE, null, { authType: "magic" }).authType,
|
||||
).toBe("direct");
|
||||
});
|
||||
});
|
||||
|
||||
describe("portable logins", () => {
|
||||
it("round-trips through an export, splitting fields by the declaration", () => {
|
||||
const portable = toPortableLogins([stored]);
|
||||
expect(portable.spice.fields).toEqual({ display: "2", ticket: "t-saved" });
|
||||
expect(fromPortableLogin("spice", portable.spice, null)).toEqual(stored);
|
||||
});
|
||||
|
||||
it("keeps every field of an undeclared protocol secret", () => {
|
||||
expect(
|
||||
fromPortableLogin("gone", { fields: { display: "1" } }, null)
|
||||
?.secretFields,
|
||||
).toEqual({ display: "1" });
|
||||
});
|
||||
|
||||
it("drops a credential link a user cannot use", async () => {
|
||||
const patch = await keepUsableProtocolCredentials(
|
||||
new Map([
|
||||
["spice", { authType: "credential", credentialId: 5 }],
|
||||
["remote-x", { authType: "credential", credentialId: 6 }],
|
||||
]),
|
||||
"importer",
|
||||
);
|
||||
expect(patch.get("spice")).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 5,
|
||||
});
|
||||
expect(patch.get("remote-x")).toEqual({
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
findHostProtocol,
|
||||
isAuthOverrideProtocol,
|
||||
listHostProtocols,
|
||||
plainFieldKeys,
|
||||
secretFieldKeys,
|
||||
setHostProtocolSource,
|
||||
type DeclaredHostProtocol,
|
||||
} from "../../../hosts/protocol-auth/registry.js";
|
||||
|
||||
const SPICE: DeclaredHostProtocol = {
|
||||
id: "spice",
|
||||
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
|
||||
pluginId: "spice-plugin",
|
||||
pluginName: "Spice",
|
||||
};
|
||||
|
||||
afterEach(() => setHostProtocolSource(() => []));
|
||||
|
||||
describe("the host protocol registry", () => {
|
||||
it("knows nothing until a manifest declares it", () => {
|
||||
expect(listHostProtocols()).toEqual([]);
|
||||
expect(findHostProtocol("spice")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the first plugin to declare an id", () => {
|
||||
setHostProtocolSource(() => [
|
||||
SPICE,
|
||||
{ ...SPICE, pluginId: "late", pluginName: "Late" },
|
||||
]);
|
||||
expect(listHostProtocols()).toHaveLength(1);
|
||||
expect(findHostProtocol("spice")?.pluginId).toBe("spice-plugin");
|
||||
});
|
||||
|
||||
it("allows overrides for ssh and declared protocols only", () => {
|
||||
setHostProtocolSource(() => [SPICE]);
|
||||
expect(isAuthOverrideProtocol("ssh")).toBe(true);
|
||||
expect(isAuthOverrideProtocol("spice")).toBe(true);
|
||||
expect(isAuthOverrideProtocol("rdp")).toBe(false);
|
||||
expect(isAuthOverrideProtocol(7)).toBe(false);
|
||||
});
|
||||
|
||||
it("splits declared fields into plain and secret", () => {
|
||||
expect(plainFieldKeys(SPICE)).toEqual(["display"]);
|
||||
expect(secretFieldKeys(SPICE)).toEqual(["ticket"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeProtocolAuthForRecipient } from "../../../hosts/protocol-auth/summary.js";
|
||||
|
||||
const summaries = {
|
||||
spice: {
|
||||
authType: "credential",
|
||||
credentialId: 4,
|
||||
username: null,
|
||||
fields: { display: "2" },
|
||||
hasPassword: true,
|
||||
secretFieldKeys: ["ticket"],
|
||||
},
|
||||
};
|
||||
|
||||
describe("sanitizeProtocolAuthForRecipient", () => {
|
||||
it("keeps only the auth type at connect level", () => {
|
||||
expect(sanitizeProtocolAuthForRecipient(summaries, "connect")).toEqual({
|
||||
spice: { authType: "credential" },
|
||||
});
|
||||
});
|
||||
|
||||
it("never says whether secrets are set, at any level", () => {
|
||||
for (const level of ["view", "edit", "manage"]) {
|
||||
expect(sanitizeProtocolAuthForRecipient(summaries, level)).toEqual({
|
||||
spice: {
|
||||
authType: "credential",
|
||||
credentialId: 4,
|
||||
username: null,
|
||||
fields: { display: "2" },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("answers an empty map for anything else", () => {
|
||||
expect(sanitizeProtocolAuthForRecipient(null, "view")).toEqual({});
|
||||
expect(sanitizeProtocolAuthForRecipient({ spice: 3 }, "view")).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,513 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "user-1",
|
||||
globalSharingEnabled: true,
|
||||
hosts: new Map<number, { userId: string; allowSessionSharing: boolean }>(),
|
||||
hostOwnerAccess: new Map<string, boolean>(), // `${userId}:${hostId}` -> hasAccess
|
||||
sshSessions: new Map<string, { userId: string; isConnected: boolean }>(),
|
||||
guacSessions: new Map<
|
||||
string,
|
||||
{ ownerUserId: string; hostId: number; protocol: string }
|
||||
>(),
|
||||
shares: new Map<string, Record<string, unknown>>(),
|
||||
admins: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
_action: string,
|
||||
) => ({
|
||||
hasAccess: state.hostOwnerAccess.get(`${userId}:${hostId}`) ?? false,
|
||||
}),
|
||||
isAdmin: async (userId: string) => state.admins.has(userId),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
getSession: (sessionId: string) => {
|
||||
const session = state.sshSessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
return { ...session };
|
||||
},
|
||||
ownerEndSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/guacamole/guacamole-server.js", () => ({
|
||||
getGuacSessionInfo: (guacamoleConnectionId: string) =>
|
||||
state.guacSessions.get(guacamoleConnectionId) ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
|
||||
GuacamoleTokenService: {
|
||||
getInstance: () => ({
|
||||
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
|
||||
`join-token:${guacamoleConnectionId}:${readOnly}`,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSessionShareRepository: () => ({
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
const row = {
|
||||
...input,
|
||||
createdAt: "2026-07-20T00:00:00.000Z",
|
||||
revokedAt: null,
|
||||
lastJoinedAt: null,
|
||||
joinCount: 0,
|
||||
};
|
||||
state.shares.set(input.id as string, row);
|
||||
return row;
|
||||
},
|
||||
findById: async (id: string) => state.shares.get(id) ?? null,
|
||||
findByLinkToken: async (linkToken: string) => {
|
||||
for (const share of state.shares.values()) {
|
||||
if (
|
||||
share.linkToken === linkToken &&
|
||||
!share.revokedAt &&
|
||||
(share.expiresAt as string) > new Date().toISOString()
|
||||
) {
|
||||
return share;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
findActiveSharesForHost: async (hostId: number, ownerUserId: string) => {
|
||||
return [...state.shares.values()].filter(
|
||||
(s) =>
|
||||
s.hostId === hostId && s.ownerUserId === ownerUserId && !s.revokedAt,
|
||||
);
|
||||
},
|
||||
revoke: async (shareId: string, requestingUserId: string) => {
|
||||
const share = state.shares.get(shareId);
|
||||
if (!share || share.ownerUserId !== requestingUserId) return false;
|
||||
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||
return true;
|
||||
},
|
||||
revokeAsAdmin: async (shareId: string) => {
|
||||
const share = state.shares.get(shareId);
|
||||
if (!share) return false;
|
||||
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||
return true;
|
||||
},
|
||||
touchShareUsage: async () => {},
|
||||
recordParticipantJoin: async () => ({ id: 1 }),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
getBoolean: async () => state.globalSharingEnabled,
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async (hostId: number) =>
|
||||
state.hosts.get(hostId)?.userId ?? null,
|
||||
findHostById: async (hostId: number) => {
|
||||
const host = state.hosts.get(hostId);
|
||||
if (!host) return null;
|
||||
return { allowSessionSharing: host.allowSessionSharing };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../hosts/session-sharing/routes.js");
|
||||
|
||||
type RouteLayer = {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: {
|
||||
handle: (req: Request, res: Response, next: () => void) => unknown;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
function findHandlers(method: string, path: string) {
|
||||
const layers = (router as unknown as { stack: RouteLayer[] }).stack;
|
||||
const layer = layers.find(
|
||||
(l) => l.route?.path === path && l.route.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
return layer.route.stack.map((s) => s.handle);
|
||||
}
|
||||
|
||||
function makeReqRes(overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
}) {
|
||||
const req = {
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
headers: {},
|
||||
ip: overrides.ip ?? "127.0.0.1",
|
||||
socket: { remoteAddress: overrides.ip ?? "127.0.0.1" },
|
||||
} as unknown as Request;
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
(this as unknown as { statusCode: number }).statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
} = {},
|
||||
) {
|
||||
const handlers = findHandlers(method, path);
|
||||
const { req, res } = makeReqRes(overrides);
|
||||
|
||||
for (const handler of handlers) {
|
||||
let calledNext = false;
|
||||
await handler(req, res, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
if (!calledNext) break;
|
||||
}
|
||||
|
||||
return res as unknown as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "user-1";
|
||||
state.globalSharingEnabled = true;
|
||||
state.hosts = new Map([
|
||||
[1, { userId: "user-1", allowSessionSharing: true }],
|
||||
[2, { userId: "user-1", allowSessionSharing: false }],
|
||||
]);
|
||||
state.hostOwnerAccess = new Map([["user-2:1", true]]);
|
||||
state.sshSessions = new Map([
|
||||
["session-1", { userId: "user-1", isConnected: true }],
|
||||
]);
|
||||
state.guacSessions = new Map([
|
||||
["guac-conn-1", { ownerUserId: "user-1", hostId: 1, protocol: "vnc" }],
|
||||
]);
|
||||
state.shares = new Map();
|
||||
state.admins = new Set();
|
||||
});
|
||||
|
||||
describe("POST /session-sharing/create", () => {
|
||||
it("rejects a caller who does not own the live session", async () => {
|
||||
state.currentUserId = "user-2";
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "You do not own this live session",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a link share for the session owner", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ shareId: expect.any(String) });
|
||||
expect((res.jsonBody as Record<string, unknown>).linkToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects a user share when the target lacks host access", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "user",
|
||||
targetUserId: "no-access-user",
|
||||
permissionLevel: "read-write",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "Target user does not have access to this host",
|
||||
});
|
||||
});
|
||||
|
||||
it("global kill switch overrides an enabled per-host toggle", async () => {
|
||||
state.globalSharingEnabled = false;
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
error: "Session sharing is disabled for this host",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when the per-host toggle is off even though global is on", async () => {
|
||||
const res = await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 2,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /session-sharing/resolve/:linkToken", () => {
|
||||
async function createActiveLinkShare(
|
||||
overrides: Partial<Record<string, unknown>> = {},
|
||||
) {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()];
|
||||
return share as { linkToken: string; id: string };
|
||||
}
|
||||
|
||||
it("never includes hostname, ip, username, or hostId in the response body", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.jsonBody as Record<string, unknown>;
|
||||
const serialized = JSON.stringify(body).toLowerCase();
|
||||
|
||||
expect(body).not.toHaveProperty("hostname");
|
||||
expect(body).not.toHaveProperty("ip");
|
||||
expect(body).not.toHaveProperty("username");
|
||||
expect(body).not.toHaveProperty("hostId");
|
||||
expect(body).not.toHaveProperty("hostName");
|
||||
expect(serialized).not.toContain("10.0.0");
|
||||
expect(serialized).not.toContain("hostname");
|
||||
expect(serialized).not.toContain('"ip"');
|
||||
expect(serialized).not.toContain("username");
|
||||
});
|
||||
|
||||
it("returns only protocol/permissionLevel/wsPath(/connectParams) for ssh", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.jsonBody).toEqual({
|
||||
protocol: "ssh",
|
||||
permissionLevel: "read-only",
|
||||
wsPath: `/terminal/ws?shareToken=${encodeURIComponent(share.linkToken)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("mints a fresh join token for guac protocols", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "guac-conn-1",
|
||||
protocol: "vnc",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as {
|
||||
linkToken: string;
|
||||
}[];
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((res.jsonBody as Record<string, unknown>).connectParams).toEqual({
|
||||
token: "join-token:guac-conn-1:true",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown link token", async () => {
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: "does-not-exist" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a revoked link token", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
await invoke("delete", "/:shareId", { params: { shareId: share.id } });
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an expired link token", async () => {
|
||||
state.shares.set("share-expired", {
|
||||
id: "share-expired",
|
||||
hostId: 1,
|
||||
ownerUserId: "user-1",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
shareType: "link",
|
||||
linkToken: "expired-token",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: "2000-01-01T00:00:00.000Z",
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: "expired-token" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("re-checks the global kill switch at resolve time, not just at creation time", async () => {
|
||||
const share = await createActiveLinkShare();
|
||||
|
||||
state.globalSharingEnabled = false;
|
||||
|
||||
const res = await invoke("get", "/resolve/:linkToken", {
|
||||
params: { linkToken: share.linkToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /session-sharing/:shareId", () => {
|
||||
it("allows the owner to revoke their own share", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a non-owner, non-admin caller", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
state.currentUserId = "user-2";
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("allows an admin to revoke someone else's share", async () => {
|
||||
await invoke("post", "/create", {
|
||||
body: {
|
||||
hostId: 1,
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
shareType: "link",
|
||||
permissionLevel: "read-only",
|
||||
},
|
||||
});
|
||||
const [share] = [...state.shares.values()] as { id: string }[];
|
||||
|
||||
state.currentUserId = "admin-1";
|
||||
state.admins.add("admin-1");
|
||||
const res = await invoke("delete", "/:shareId", {
|
||||
params: { shareId: share.id },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isRetriableDnsError,
|
||||
pickSshAddress,
|
||||
resolveHostForSshConnect,
|
||||
resolveSshConnectConfigHost,
|
||||
shouldResolveBeforeSshConnect,
|
||||
@@ -76,4 +77,25 @@ describe("SSH DNS resolution", () => {
|
||||
resolvedHost: "10.0.0.6",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an IPv4 address when a name has both", async () => {
|
||||
const lookup = vi.fn().mockResolvedValue([
|
||||
{ address: "::1", family: 6 },
|
||||
{ address: "10.0.0.7", family: 4 },
|
||||
]);
|
||||
|
||||
await expect(resolveHostForSshConnect("box", lookup)).resolves.toEqual({
|
||||
host: "10.0.0.7",
|
||||
resolvedAddress: "10.0.0.7",
|
||||
attempts: 1,
|
||||
});
|
||||
expect(lookup).toHaveBeenCalledWith("box", { all: true });
|
||||
});
|
||||
|
||||
it("falls back to IPv6 when that is all there is", () => {
|
||||
expect(pickSshAddress([{ address: "2001:db8::2", family: 6 }])).toBe(
|
||||
"2001:db8::2",
|
||||
);
|
||||
expect(() => pickSshAddress([])).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSshKeepalive } from "../../hosts/ssh-keepalive.js";
|
||||
|
||||
describe("resolveSshKeepalive", () => {
|
||||
it("preserves zero to disable SSH keepalives", () => {
|
||||
expect(resolveSshKeepalive(0, 0, 30000, 5)).toEqual({
|
||||
keepaliveInterval: 0,
|
||||
keepaliveCountMax: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses defaults when keepalive settings are absent", () => {
|
||||
expect(resolveSshKeepalive(undefined, undefined, 60000, 5)).toEqual({
|
||||
keepaliveInterval: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces the existing minimums for positive settings", () => {
|
||||
expect(resolveSshKeepalive(1, 0.5, 30000, 5)).toEqual({
|
||||
keepaliveInterval: 5000,
|
||||
keepaliveCountMax: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasSshOptions,
|
||||
parseSshOptions,
|
||||
sshOptionsForWrite,
|
||||
} from "../../hosts/ssh-options.js";
|
||||
|
||||
describe("parseSshOptions", () => {
|
||||
it("keeps the connection options, typed, and drops everything else", () => {
|
||||
expect(
|
||||
parseSshOptions(
|
||||
JSON.stringify({
|
||||
keepaliveInterval: "30",
|
||||
keepaliveCountMax: 3,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentSocketPath: "/run/agent",
|
||||
agentIdentity: null,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "A", value: 1 }, null],
|
||||
theme: "nord",
|
||||
cfAccessClientId: "gone",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 3,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentSocketPath: "/run/agent",
|
||||
agentIdentity: null,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "A", value: "1" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("reads twice-stringified rows and ignores garbage", () => {
|
||||
expect(
|
||||
parseSshOptions(JSON.stringify(JSON.stringify({ keepaliveInterval: 5 }))),
|
||||
).toEqual({ keepaliveInterval: 5 });
|
||||
expect(parseSshOptions("nope")).toEqual({});
|
||||
expect(parseSshOptions(null)).toEqual({});
|
||||
expect(parseSshOptions([1])).toEqual({});
|
||||
});
|
||||
|
||||
it("says whether a value carries any option", () => {
|
||||
expect(hasSshOptions({ theme: "nord" })).toBe(false);
|
||||
expect(hasSshOptions({ agentForwarding: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sshOptionsForWrite", () => {
|
||||
it("stores the payload's own options", () => {
|
||||
expect(
|
||||
sshOptionsForWrite({ sshOptions: { keepaliveInterval: 9, junk: 1 } }),
|
||||
).toBe(JSON.stringify({ keepaliveInterval: 9 }));
|
||||
expect(sshOptionsForWrite({ sshOptions: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("takes them from a 2.8 terminalConfig when the payload has none", () => {
|
||||
expect(
|
||||
sshOptionsForWrite({
|
||||
terminalConfig: { keepaliveCountMax: 2, theme: "nord" },
|
||||
}),
|
||||
).toBe(JSON.stringify({ keepaliveCountMax: 2 }));
|
||||
});
|
||||
|
||||
it("leaves the column alone when neither carries any", () => {
|
||||
expect(sshOptionsForWrite({ terminalConfig: { theme: "nord" } })).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(sshOptionsForWrite({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: vi.fn(),
|
||||
getCurrentSettingValue: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
pluginLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { HostStatusService, toStatusTarget } =
|
||||
await import("../../../hosts/status/host-status-service.js");
|
||||
const { hostSessionStatus } =
|
||||
await import("../../../hosts/host-session-status.js");
|
||||
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
|
||||
|
||||
type Target = ReturnType<typeof toStatusTarget>;
|
||||
|
||||
function target(id: number, extra: Partial<Target> = {}): Target {
|
||||
return {
|
||||
id,
|
||||
userId: "owner",
|
||||
ip: `10.0.0.${id}`,
|
||||
port: 22,
|
||||
connectionType: "ssh",
|
||||
jumpHosts: [],
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(
|
||||
targets: Target[],
|
||||
reachable = true,
|
||||
shared: Record<string, number[]> = {},
|
||||
) {
|
||||
const emitted: unknown[] = [];
|
||||
const ping = vi.fn(async () => reachable);
|
||||
const pingThroughJumpHosts = vi.fn(async () => reachable);
|
||||
const loadTargets = vi.fn(
|
||||
async (filter: { userId?: string; hostIds?: number[] }) =>
|
||||
targets.filter(
|
||||
(t) =>
|
||||
(!filter.userId || t.userId === filter.userId) &&
|
||||
(!filter.hostIds || filter.hostIds.includes(t.id)),
|
||||
),
|
||||
);
|
||||
const service = new HostStatusService({
|
||||
loadTargets,
|
||||
loadSharedHostIds: async (userId) => shared[userId] ?? [],
|
||||
ping,
|
||||
pingThroughJumpHosts,
|
||||
globalInterval: () => 60,
|
||||
emit: (payload) => emitted.push(payload),
|
||||
});
|
||||
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
let active: InstanceType<typeof HostStatusService> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
active?.stop();
|
||||
active = null;
|
||||
pluginEvents.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("HostStatusService", () => {
|
||||
it("starts a user's own hosts and reports them online", async () => {
|
||||
const { service, emitted, ping } = setup([target(1), target(2)]);
|
||||
active = service;
|
||||
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.1", 22);
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
expect(emitted).toContainEqual({
|
||||
hostId: 1,
|
||||
ownerUserId: "owner",
|
||||
status: "online",
|
||||
previous: null,
|
||||
online: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("checks a host shared with the user before its owner ever asks", async () => {
|
||||
const { service, ping } = setup([target(5)], true, { recipient: [5] });
|
||||
active = service;
|
||||
|
||||
await service.statusesFor("recipient", null);
|
||||
await flush();
|
||||
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.5", 22);
|
||||
expect(service.get(5)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("only starts the shared hosts a desktop request names", async () => {
|
||||
const { service, ping } = setup([target(5), target(6)], true, {
|
||||
recipient: [5, 6],
|
||||
});
|
||||
active = service;
|
||||
|
||||
await service.statusesFor("recipient", new Set([6]));
|
||||
await flush();
|
||||
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.6", 22);
|
||||
expect(ping).not.toHaveBeenCalledWith("10.0.0.5", 22);
|
||||
});
|
||||
|
||||
it("reports offline for a host that does not answer", async () => {
|
||||
const { service } = setup([target(1)], false);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("offline");
|
||||
});
|
||||
|
||||
it("never checks a host with status checks off", async () => {
|
||||
const { service, ping } = setup([target(1, { statusCheckEnabled: false })]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).not.toHaveBeenCalled();
|
||||
expect(service.get(1)).toBeNull();
|
||||
});
|
||||
|
||||
it("goes through the jump hosts when a host has them", async () => {
|
||||
const { service, ping, pingThroughJumpHosts } = setup([
|
||||
target(1, { jumpHosts: [{ hostId: 9 }] }),
|
||||
]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).not.toHaveBeenCalled();
|
||||
expect(pingThroughJumpHosts).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("asks a registered plugin for a protocol's port", async () => {
|
||||
const { service, ping } = setup([target(1, { connectionType: "rdp" })]);
|
||||
active = service;
|
||||
service.registerPort("rdp", async () => 3390);
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3390);
|
||||
});
|
||||
|
||||
it("falls back to the host's port when no plugin knows the protocol", async () => {
|
||||
const { service, ping } = setup([
|
||||
target(1, { connectionType: "rdp", port: 3389 }),
|
||||
]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389);
|
||||
});
|
||||
|
||||
it("warns about a failed login until a login works again", async () => {
|
||||
const { service, emitted } = setup([target(1)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
service.reportLogin(1, { ok: false });
|
||||
expect(service.get(1)?.status).toBe("reachable");
|
||||
expect(emitted.at(-1)).toMatchObject({
|
||||
status: "reachable",
|
||||
previous: "online",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(75_000);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("reachable");
|
||||
|
||||
service.reportLogin(1, { ok: true });
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
vi.advanceTimersByTime(75_000);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("marks a changed host key and clears it once the new key is accepted", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false, hostKeyChanged: true });
|
||||
expect(service.get(1)).toMatchObject({
|
||||
status: "reachable",
|
||||
reason: "host_key_changed",
|
||||
});
|
||||
|
||||
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId: 1 });
|
||||
expect(service.get(1)?.reason).toBeUndefined();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("clears a failed login when the host is edited", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false });
|
||||
|
||||
pluginEvents.emit(TOPICS.hostUpdated, { hostId: 1 });
|
||||
await flush();
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("clears a failed login when a terminal session opens", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false });
|
||||
|
||||
const release = hostSessionStatus.register(1);
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
release();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("shows an offline host as offline whatever the login said", async () => {
|
||||
const { service } = setup([target(1)], false);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false });
|
||||
expect(service.get(1)?.status).toBe("offline");
|
||||
service.reportLogin(1, { ok: true });
|
||||
vi.advanceTimersByTime(75_000);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("offline");
|
||||
});
|
||||
|
||||
it("only checks the requested hosts on the desktop app", async () => {
|
||||
const { service, ping } = setup([target(1), target(2)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", new Set([2]));
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledTimes(1);
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.2", 22);
|
||||
});
|
||||
|
||||
it("checks again on its interval", async () => {
|
||||
const { service, ping } = setup([target(1, { statusCheckInterval: 10 })]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(15_000);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("checks a host on demand unless the last result is fresh", async () => {
|
||||
const { service, ping } = setup([target(1)]);
|
||||
active = service;
|
||||
expect(await service.check(1)).toMatchObject({ status: "online" });
|
||||
expect(await service.check(1)).toMatchObject({ status: "online" });
|
||||
expect(ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets a deleted host", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
pluginEvents.emit(TOPICS.hostDeleted, { hostId: 1 });
|
||||
expect(service.get(1)).toBeNull();
|
||||
});
|
||||
|
||||
it("never logs in to check a host", async () => {
|
||||
// The only transports are the two ping functions: nothing here can
|
||||
// authenticate, so a RADIUS or Duo backed host gets no 2FA push.
|
||||
const { service, ping, pingThroughJumpHosts } = setup([target(1)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping.mock.calls[0]).toEqual(["10.0.0.1", 22]);
|
||||
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStatusTarget", () => {
|
||||
it("parses the jump hosts and defaults the connection type", () => {
|
||||
expect(
|
||||
toStatusTarget({
|
||||
id: 1,
|
||||
userId: "u",
|
||||
ip: "h",
|
||||
port: 22,
|
||||
connectionType: "",
|
||||
jumpHosts: '[{"hostId":3},{"bad":1}]',
|
||||
statusCheckEnabled: false,
|
||||
statusCheckInterval: 30,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 1,
|
||||
userId: "u",
|
||||
ip: "h",
|
||||
port: 22,
|
||||
connectionType: "ssh",
|
||||
jumpHosts: [{ hostId: 3 }],
|
||||
statusCheckEnabled: false,
|
||||
statusCheckInterval: 30,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHostKeyVerificationError,
|
||||
statusAfterAuthentication,
|
||||
statusAfterReachabilityCheck,
|
||||
} from "../../../hosts/status/host-status.js";
|
||||
|
||||
describe("isHostKeyVerificationError", () => {
|
||||
it.each([
|
||||
"Host denied (verification failed)",
|
||||
"Host key changed - please connect via Terminal to verify the new key",
|
||||
])("classifies %s", (message) => {
|
||||
expect(isHostKeyVerificationError(new Error(message))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify ordinary authentication failures", () => {
|
||||
expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host availability status", () => {
|
||||
it("calls a host whose port answers online", () => {
|
||||
expect(statusAfterReachabilityCheck(true, false)).toBe("online");
|
||||
expect(statusAfterReachabilityCheck(false, false)).toBe("offline");
|
||||
});
|
||||
|
||||
it("warns about a reachable host whose last login failed", () => {
|
||||
expect(statusAfterReachabilityCheck(true, true)).toBe("reachable");
|
||||
expect(statusAfterReachabilityCheck(false, true)).toBe("offline");
|
||||
});
|
||||
|
||||
it("marks successful authentication online", () => {
|
||||
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
|
||||
});
|
||||
|
||||
it("downgrades failed authentication without hiding reachability", () => {
|
||||
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
|
||||
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
|
||||
});
|
||||
});
|
||||
+2
-107
@@ -1,36 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
authFailureTracker,
|
||||
canStartInitialMetrics,
|
||||
ConcurrentLimiter,
|
||||
HostPollCache,
|
||||
metricsConcurrencyFor,
|
||||
} from "../../../hosts/metrics/state.js";
|
||||
|
||||
describe("auth failure tracking", () => {
|
||||
it("permanently pauses polling after a host key mismatch", () => {
|
||||
const hostId = 991266;
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
expect(authFailureTracker.getSkipReason(hostId)).toContain(
|
||||
"host key changed",
|
||||
);
|
||||
|
||||
authFailureTracker.reset(hostId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial metrics admission", () => {
|
||||
it("requires both an active viewer and a confirmed online status", () => {
|
||||
expect(canStartInitialMetrics("online", true)).toBe(true);
|
||||
expect(canStartInitialMetrics("reachable", true)).toBe(true);
|
||||
expect(canStartInitialMetrics("offline", true)).toBe(false);
|
||||
expect(canStartInitialMetrics(undefined, true)).toBe(false);
|
||||
expect(canStartInitialMetrics("online", false)).toBe(false);
|
||||
expect(canStartInitialMetrics(undefined, true, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ConcurrentLimiter } from "../../../hosts/status/limiter.js";
|
||||
|
||||
describe("ConcurrentLimiter", () => {
|
||||
it("never exceeds max concurrent runners", async () => {
|
||||
@@ -191,77 +160,3 @@ describe("ConcurrentLimiter", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("metricsConcurrencyFor", () => {
|
||||
it("keeps a floor for small installs", () => {
|
||||
expect(metricsConcurrencyFor(0, {})).toBe(5);
|
||||
expect(metricsConcurrencyFor(1, {})).toBe(5);
|
||||
expect(metricsConcurrencyFor(60, {})).toBe(5);
|
||||
});
|
||||
|
||||
it("scales up with the fleet", () => {
|
||||
expect(metricsConcurrencyFor(200, {})).toBe(10);
|
||||
expect(metricsConcurrencyFor(500, {})).toBe(25);
|
||||
});
|
||||
|
||||
it("caps so a huge fleet cannot exhaust the host", () => {
|
||||
expect(metricsConcurrencyFor(100000, {})).toBe(50);
|
||||
});
|
||||
|
||||
it("lets an operator override the sizing", () => {
|
||||
expect(metricsConcurrencyFor(1000, { METRICS_POLL_CONCURRENCY: "8" })).toBe(
|
||||
8,
|
||||
);
|
||||
});
|
||||
|
||||
it("still caps an oversized override", () => {
|
||||
expect(
|
||||
metricsConcurrencyFor(10, { METRICS_POLL_CONCURRENCY: "9999" }),
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
it("ignores a nonsense override", () => {
|
||||
expect(
|
||||
metricsConcurrencyFor(500, { METRICS_POLL_CONCURRENCY: "abc" }),
|
||||
).toBe(25);
|
||||
});
|
||||
|
||||
it("sweeps 500 hosts inside a 30s interval, which the old fixed 5 could not", () => {
|
||||
const POLL_MS = 400;
|
||||
const hosts = 500;
|
||||
const sweepAt = (c: number) => Math.ceil(hosts / c) * POLL_MS;
|
||||
|
||||
expect(sweepAt(5)).toBeGreaterThan(30_000);
|
||||
expect(sweepAt(metricsConcurrencyFor(hosts, {}))).toBeLessThan(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostPollCache", () => {
|
||||
it("returns cached host within TTL for the same user", () => {
|
||||
const cache = new HostPollCache<{ id: number; name: string }>(60_000);
|
||||
cache.set(1, "user-a", { id: 1, name: "alpha" });
|
||||
expect(cache.get(1, "user-a")).toEqual({ id: 1, name: "alpha" });
|
||||
expect(cache.get(1, "user-b")).toBeNull();
|
||||
});
|
||||
|
||||
it("expires entries after TTL", () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new HostPollCache<{ id: number }>(1_000);
|
||||
cache.set(7, "u", { id: 7 });
|
||||
expect(cache.get(7, "u")).toEqual({ id: 7 });
|
||||
vi.advanceTimersByTime(1_001);
|
||||
expect(cache.get(7, "u")).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("invalidate drops a host or the whole cache", () => {
|
||||
const cache = new HostPollCache<{ id: number }>(60_000);
|
||||
cache.set(1, "u", { id: 1 });
|
||||
cache.set(2, "u", { id: 2 });
|
||||
cache.invalidate(1);
|
||||
expect(cache.get(1, "u")).toBeNull();
|
||||
expect(cache.get(2, "u")).toEqual({ id: 2 });
|
||||
cache.invalidate();
|
||||
expect(cache.get(2, "u")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { tcpPingThroughJumpHost } from "../../../hosts/status/tcp-ping.js";
|
||||
|
||||
describe("tcpPingThroughJumpHost", () => {
|
||||
it("reports the final destination online when forwarding succeeds", async () => {
|
||||
const stream = { destroy: vi.fn() };
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
|
||||
expect(host).toBe("private.example");
|
||||
expect(port).toBe(22);
|
||||
callback(undefined, stream);
|
||||
}),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
await expect(
|
||||
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
|
||||
).resolves.toBe(true);
|
||||
expect(stream.destroy).toHaveBeenCalledOnce();
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports the final destination offline when forwarding fails", async () => {
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
|
||||
callback(new Error("Connection refused"));
|
||||
}),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
await expect(
|
||||
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
|
||||
).resolves.toBe(false);
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports the final destination offline when forwarding times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn(),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
const result = tcpPingThroughJumpHost(
|
||||
jumpClient,
|
||||
"private.example",
|
||||
22,
|
||||
5000,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
sent: [] as Array<Record<string, unknown>>,
|
||||
upsert: vi.fn(async () => undefined),
|
||||
parseInfo: {
|
||||
publicKeyLine: "ssh-ed25519 TESTKEY",
|
||||
principals: ["alice"],
|
||||
validAfter: new Date(Date.now() - 60_000),
|
||||
validBefore: new Date(Date.now() + 60 * 60_000),
|
||||
},
|
||||
idToken: "",
|
||||
command: null as Record<string, string> | null,
|
||||
remoteResult: null as { ok: boolean; message: string } | null,
|
||||
runtimeComplete: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 1) },
|
||||
}));
|
||||
vi.mock("../../utils/field-crypto.js", () => ({
|
||||
FieldCrypto: {
|
||||
encryptField: (value: string, _key: Buffer, _id: string, field: string) =>
|
||||
`${field}:${value}`,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) =>
|
||||
({
|
||||
step_ca_url: "https://ca.example",
|
||||
step_ca_fingerprint: "a".repeat(64),
|
||||
step_ca_provisioner: "oidc",
|
||||
})[key],
|
||||
}),
|
||||
createCurrentOpksshTokenRepository: () => ({ upsert: state.upsert }),
|
||||
}));
|
||||
vi.mock("../../utils/step-ca-egress.js", () => ({
|
||||
readStepCaPrivateAllowlist: async () => [],
|
||||
}));
|
||||
vi.mock("../../utils/step-ca-client.js", () => ({
|
||||
fetchRootCertificate: async () => "ROOT",
|
||||
findOidcProvisioner: async () => ({
|
||||
clientID: "client",
|
||||
configurationEndpoint: "https://idp.example/.well-known/openid",
|
||||
}),
|
||||
discoverOidcEndpoints: async () => ({
|
||||
authorizationEndpoint: "https://idp.example/auth",
|
||||
tokenEndpoint: "https://idp.example/token",
|
||||
}),
|
||||
createPkce: () => ({ verifier: "verifier", challenge: "challenge" }),
|
||||
generateSshKeyPair: () => ({
|
||||
publicKeyLine: "ssh-ed25519 TESTKEY",
|
||||
privateKeyPem: "PRIVATE",
|
||||
}),
|
||||
buildAuthorizationUrl: (input: Record<string, string>) => {
|
||||
const url = new URL("https://idp.example/auth");
|
||||
url.searchParams.set("state", input.state);
|
||||
url.searchParams.set("nonce", input.nonce);
|
||||
return url.toString();
|
||||
},
|
||||
exchangeCodeForIdToken: async () => state.idToken,
|
||||
decodeJwtClaims: (token: string) => {
|
||||
const payload = token.split(".")[1];
|
||||
return payload
|
||||
? JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
|
||||
: {};
|
||||
},
|
||||
signSshCertificate: async () => "CERT",
|
||||
parseSshCertificate: () => state.parseInfo,
|
||||
}));
|
||||
vi.mock("../../hosts/step-ca-runtime.js", () => ({
|
||||
stepCaRuntime: {
|
||||
register: vi.fn(async () => undefined),
|
||||
remove: vi.fn(async () => undefined),
|
||||
submit: vi.fn(async () => true),
|
||||
takeCommand: vi.fn(async () => {
|
||||
const command = state.command;
|
||||
state.command = null;
|
||||
return command;
|
||||
}),
|
||||
complete: state.runtimeComplete,
|
||||
takeResult: vi.fn(async () => {
|
||||
const result = state.remoteResult;
|
||||
state.remoteResult = null;
|
||||
return result;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { cancelStepCaAuth, completeStepCaAuth, startStepCaAuth } =
|
||||
await import("../../hosts/step-ca-auth.js");
|
||||
|
||||
function jwt(payload: object): string {
|
||||
return `x.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.y`;
|
||||
}
|
||||
|
||||
function fakeWs(): WebSocket {
|
||||
return {
|
||||
send: (raw: string) => state.sent.push(JSON.parse(raw)),
|
||||
once: vi.fn(),
|
||||
} as unknown as WebSocket;
|
||||
}
|
||||
|
||||
async function start(): Promise<{ requestId: string; nonce: string }> {
|
||||
await startStepCaAuth(
|
||||
"user-1",
|
||||
7,
|
||||
"alice",
|
||||
fakeWs(),
|
||||
"https://termix.example",
|
||||
);
|
||||
const chooser = state.sent.at(-1)!;
|
||||
const url = new URL(String(chooser.url));
|
||||
return {
|
||||
requestId: String(chooser.requestId),
|
||||
nonce: url.searchParams.get("nonce")!,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.sent.length = 0;
|
||||
state.upsert.mockClear();
|
||||
state.runtimeComplete.mockClear();
|
||||
state.command = null;
|
||||
state.remoteResult = null;
|
||||
state.parseInfo = {
|
||||
publicKeyLine: "ssh-ed25519 TESTKEY",
|
||||
principals: ["alice"],
|
||||
validAfter: new Date(Date.now() - 60_000),
|
||||
validBefore: new Date(Date.now() + 60 * 60_000),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Step CA authentication", () => {
|
||||
it("stores only a certificate bound to the nonce, key, principal and validity window", async () => {
|
||||
const { requestId, nonce } = await start();
|
||||
state.idToken = jwt({ nonce, email: "alice@example.com" });
|
||||
|
||||
await expect(
|
||||
completeStepCaAuth({ state: requestId, code: "code" }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
message: "Signed in. You can close this window.",
|
||||
});
|
||||
expect(state.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", hostId: 7 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a token without the requested nonce", async () => {
|
||||
const { requestId } = await start();
|
||||
state.idToken = jwt({ email: "alice@example.com" });
|
||||
|
||||
const result = await completeStepCaAuth({ state: requestId, code: "code" });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/does not match/);
|
||||
expect(state.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a callback through Redis to the instance holding the WebSocket", async () => {
|
||||
const { requestId, nonce } = await start();
|
||||
state.idToken = jwt({ nonce });
|
||||
state.command = { state: requestId, code: "remote-code" };
|
||||
|
||||
await vi.waitFor(() => expect(state.runtimeComplete).toHaveBeenCalled(), {
|
||||
timeout: 2000,
|
||||
});
|
||||
expect(state.upsert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a result produced by another instance", async () => {
|
||||
state.remoteResult = { ok: true, message: "remote success" };
|
||||
await expect(
|
||||
completeStepCaAuth({ state: "remote-state", code: "code" }),
|
||||
).resolves.toEqual({ ok: true, message: "remote success" });
|
||||
});
|
||||
|
||||
it("cancels local sessions", async () => {
|
||||
const { requestId } = await start();
|
||||
expect(cancelStepCaAuth(requestId)).toBe(true);
|
||||
expect(cancelStepCaAuth(requestId)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseTailscaleCheckBanner,
|
||||
isTailscaleCheckCompleteBanner,
|
||||
} from "../../hosts/tailscale-check.js";
|
||||
|
||||
describe("parseTailscaleCheckBanner", () => {
|
||||
it("extracts the login URL from a real check-mode banner", () => {
|
||||
const banner =
|
||||
"# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/lefcb2f3377403\n";
|
||||
|
||||
const result = parseTailscaleCheckBanner(banner);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.url).toBe("https://login.tailscale.com/a/lefcb2f3377403");
|
||||
});
|
||||
|
||||
it("strips comment markers from the message it returns", () => {
|
||||
const banner =
|
||||
"# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/abc123\n";
|
||||
|
||||
const result = parseTailscaleCheckBanner(banner);
|
||||
|
||||
expect(result?.message).toBe(
|
||||
"Tailscale SSH requires an additional check.\nTo authenticate, visit: https://login.tailscale.com/a/abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for an ordinary MOTD banner", () => {
|
||||
const banner =
|
||||
"Welcome to Ubuntu 24.04 LTS\nLast login: Tue Aug 5 09:12:03 2026\n";
|
||||
|
||||
expect(parseTailscaleCheckBanner(banner)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a lookalike URL on another host", () => {
|
||||
const banner =
|
||||
"# To authenticate, visit: https://login.tailscale.com.evil.example/a/abc123\n";
|
||||
|
||||
expect(parseTailscaleCheckBanner(banner)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty input", () => {
|
||||
expect(parseTailscaleCheckBanner("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTailscaleCheckCompleteBanner", () => {
|
||||
it("recognises the completion banner", () => {
|
||||
expect(
|
||||
isTailscaleCheckCompleteBanner(
|
||||
"# Authentication checked with Tailscale SSH.",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises the completion banner with a time suffix", () => {
|
||||
expect(
|
||||
isTailscaleCheckCompleteBanner(
|
||||
"Authentication checked with Tailscale SSH. Time since last authentication: 0s",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match the check-required banner", () => {
|
||||
expect(
|
||||
isTailscaleCheckCompleteBanner(
|
||||
"# Tailscale SSH requires an additional check.",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match empty input", () => {
|
||||
expect(isTailscaleCheckCompleteBanner("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,57 +9,10 @@ vi.mock("fs/promises", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
MemoryAgent,
|
||||
FilteredAgent,
|
||||
resolveAgentSocket,
|
||||
} from "../../hosts/terminal-auth-helpers.js";
|
||||
|
||||
describe("MemoryAgent", () => {
|
||||
it("serves identities and signatures over the agent protocol", async () => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const parsed = ssh2Pkg.utils.parseKey(
|
||||
privateKey.export({ type: "pkcs1", format: "pem" }),
|
||||
);
|
||||
expect(parsed).not.toBeInstanceOf(Error);
|
||||
|
||||
const agent = new MemoryAgent(parsed as ParsedKey);
|
||||
const stream = await new Promise<NodeJS.ReadWriteStream>(
|
||||
(resolve, reject) => {
|
||||
agent.getStream((error, result) => {
|
||||
if (error || !result)
|
||||
reject(error ?? new Error("Missing agent stream"));
|
||||
else resolve(result);
|
||||
});
|
||||
},
|
||||
);
|
||||
const client = new ssh2Pkg.AgentProtocol(true);
|
||||
client.pipe(stream).pipe(client);
|
||||
|
||||
const identities = await new Promise<ParsedKey[]>((resolve, reject) => {
|
||||
client.getIdentities((error, keys) => {
|
||||
if (error || !keys) reject(error ?? new Error("Missing identities"));
|
||||
else resolve(keys);
|
||||
});
|
||||
});
|
||||
expect(identities).toHaveLength(1);
|
||||
expect(identities[0].getPublicSSH()).toEqual(
|
||||
(parsed as ParsedKey).getPublicSSH(),
|
||||
);
|
||||
|
||||
const data = Buffer.from("forwarded-agent-test");
|
||||
const signature = await new Promise<Buffer>((resolve, reject) => {
|
||||
client.sign(identities[0], data, (error, result) => {
|
||||
if (error || !result) reject(error ?? new Error("Missing signature"));
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
expect((parsed as ParsedKey).verify(data, signature)).toBe(true);
|
||||
|
||||
client.destroy();
|
||||
stream.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentSocket", () => {
|
||||
const originalEnv = process.env.SSH_AUTH_SOCK;
|
||||
const originalPlatform = process.platform;
|
||||
@@ -228,58 +181,3 @@ describe("FilteredAgent", () => {
|
||||
expect(identities).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryAgent unsupported extension framing", () => {
|
||||
it.each([false, true])(
|
||||
"can enumerate keys after rejecting session-bind (fragmented=%s)",
|
||||
async (fragmented) => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
});
|
||||
const key = ssh2Pkg.utils.parseKey(
|
||||
privateKey.export({ type: "pkcs1", format: "pem" }),
|
||||
) as ParsedKey;
|
||||
const agent = new MemoryAgent(key);
|
||||
const stream = await new Promise<NodeJS.ReadWriteStream>(
|
||||
(resolve, reject) => {
|
||||
agent.getStream((error, result) =>
|
||||
error || !result ? reject(error) : resolve(result),
|
||||
);
|
||||
},
|
||||
);
|
||||
const replies: Buffer[] = [];
|
||||
let buffered = Buffer.alloc(0);
|
||||
stream.on("data", (data: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, data]);
|
||||
while (
|
||||
buffered.length >= 4 &&
|
||||
buffered.length >= buffered.readUInt32BE(0) + 4
|
||||
) {
|
||||
const end = buffered.readUInt32BE(0) + 4;
|
||||
replies.push(buffered.subarray(4, end));
|
||||
buffered = buffered.subarray(end);
|
||||
}
|
||||
});
|
||||
const extension = Buffer.from("session-bind@openssh.com");
|
||||
const request = Buffer.alloc(4 + 1 + 4 + extension.length);
|
||||
request.writeUInt32BE(request.length - 4, 0);
|
||||
request[4] = 27;
|
||||
request.writeUInt32BE(extension.length, 5);
|
||||
extension.copy(request, 9);
|
||||
const identities = Buffer.from([0, 0, 0, 1, 11]);
|
||||
try {
|
||||
const requests = Buffer.concat([request, identities]);
|
||||
if (fragmented) {
|
||||
stream.write(requests.subarray(0, 7));
|
||||
stream.write(requests.subarray(7));
|
||||
} else stream.write(requests);
|
||||
await vi.waitFor(() => expect(replies).toHaveLength(2));
|
||||
expect(replies[0][0]).toBe(5); // Unsupported extension remains refused.
|
||||
expect(replies[1][0]).toBe(12); // The following identities request succeeds.
|
||||
expect(replies[1].readUInt32BE(1)).toBe(1);
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,510 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Stub all external imports before loading the module under test
|
||||
const mockCreate = vi.fn().mockResolvedValue({ id: 1 });
|
||||
const mockUpdateEnded = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSessionRecordingRepository: () => ({
|
||||
create: mockCreate,
|
||||
updateEnded: mockUpdateEnded,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock individual fs.promises methods via a stub object
|
||||
const mockMkdir = vi.fn().mockResolvedValue(undefined);
|
||||
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAppendFile = vi.fn().mockResolvedValue(undefined);
|
||||
const mockUnlink = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("fs", () => ({
|
||||
default: {
|
||||
promises: {
|
||||
mkdir: mockMkdir,
|
||||
writeFile: mockWriteFile,
|
||||
appendFile: mockAppendFile,
|
||||
readFile: vi.fn(),
|
||||
unlink: mockUnlink,
|
||||
},
|
||||
},
|
||||
promises: {
|
||||
mkdir: mockMkdir,
|
||||
writeFile: mockWriteFile,
|
||||
appendFile: mockAppendFile,
|
||||
readFile: vi.fn(),
|
||||
unlink: mockUnlink,
|
||||
},
|
||||
}));
|
||||
|
||||
const { sessionManager, isMessageAllowedForParticipant } =
|
||||
await import("../../../hosts/terminal/session-manager.js");
|
||||
|
||||
// Minimal fake WebSocket - only the surface session-manager touches.
|
||||
function makeFakeWs(readyState = 1 /* OPEN */) {
|
||||
return {
|
||||
readyState,
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
} as unknown as import("ws").WebSocket;
|
||||
}
|
||||
const WS_OPEN = 1;
|
||||
const WS_CLOSED = 3;
|
||||
|
||||
describe("TerminalSessionManager - session logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Re-apply resolved values after clearAllMocks
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockCreate.mockResolvedValue({ id: 1 });
|
||||
mockUpdateEnded.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("createSession stores sessionLoggingEnabled=true by default", () => {
|
||||
const id = sessionManager.createSession("u1", 1, "host", 80, 24);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.sessionLoggingEnabled).toBe(true);
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("createSession stores sessionLoggingEnabled=false when passed", () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.sessionLoggingEnabled).toBe(false);
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("does not write log file when sessionLoggingEnabled=false", async () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
sessionManager.bufferOutput(id, "some output");
|
||||
sessionManager.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes log file and inserts DB row when sessionLoggingEnabled=true", async () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
sessionManager.bufferOutput(id, "terminal output data");
|
||||
sessionManager.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(mockWriteFile).toHaveBeenCalledOnce();
|
||||
expect(mockCreate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not write log file when buffer is empty", async () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
sessionManager.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bufferOutput trims old data when exceeding 512KB", () => {
|
||||
const id = sessionManager.createSession(
|
||||
"u1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
const chunk = "x".repeat(300 * 1024);
|
||||
sessionManager.bufferOutput(id, chunk);
|
||||
sessionManager.bufferOutput(id, chunk);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session!.outputBufferBytes).toBeLessThanOrEqual(512 * 1024);
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalSessionManager - multiplayer participants", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockCreate.mockResolvedValue({ id: 1 });
|
||||
mockUpdateEnded.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function createConnectedSession(): string {
|
||||
const id = sessionManager.createSession(
|
||||
"owner-1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
// Mark connected without a real ssh2 stream - only isConnected is read
|
||||
// by attachWs/joinAsParticipant.
|
||||
const session = sessionManager.getSession(id)!;
|
||||
session.isConnected = true;
|
||||
return id;
|
||||
}
|
||||
|
||||
it("joinAsParticipant adds a participant without evicting the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
const session = sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
guestLabel: "Guest",
|
||||
});
|
||||
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.participants.size).toBe(2);
|
||||
const ownerParticipant = sessionManager.getParticipantForWs(
|
||||
session!,
|
||||
ownerWs,
|
||||
);
|
||||
expect(ownerParticipant?.isOwner).toBe(true);
|
||||
// The join is announced to everyone already in the session - and that is
|
||||
// the only unsolicited message the owner receives.
|
||||
expect(ownerWs.send).toHaveBeenCalledTimes(1);
|
||||
const announced = JSON.parse(
|
||||
(ownerWs.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as string,
|
||||
);
|
||||
expect(announced.type).toBe("participants");
|
||||
expect(announced.participants).toHaveLength(2);
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("setRoomShareControl makes only the controller read-write and never touches the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
const aliceWs = makeFakeWs();
|
||||
const bobWs = makeFakeWs();
|
||||
const session = sessionManager.joinAsParticipant(id, aliceWs, {
|
||||
userId: "alice",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
})!;
|
||||
sessionManager.joinAsParticipant(id, bobWs, {
|
||||
userId: "bob",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
});
|
||||
|
||||
sessionManager.setRoomShareControl(id, "stage-share", "alice");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
|
||||
).toBe("read-write");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, bobWs)?.permissionLevel,
|
||||
).toBe("read-only");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, ownerWs)?.permissionLevel,
|
||||
).toBe("read-write");
|
||||
|
||||
sessionManager.setRoomShareControl(id, "stage-share", null);
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
|
||||
).toBe("read-only");
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("disconnectShareParticipants revokes only the selected share participants", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
const aliceWs = makeFakeWs();
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
const session = sessionManager.joinAsParticipant(id, aliceWs, {
|
||||
userId: "alice",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
})!;
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
});
|
||||
|
||||
expect(
|
||||
sessionManager.disconnectShareParticipants(id, "stage-share", {
|
||||
userId: "alice",
|
||||
reason: "Removed",
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(sessionManager.getParticipantForWs(session, aliceWs)).toBeNull();
|
||||
expect(sessionManager.getParticipantForWs(session, guestWs)).not.toBeNull();
|
||||
expect(sessionManager.getParticipantForWs(session, ownerWs)?.isOwner).toBe(
|
||||
true,
|
||||
);
|
||||
expect(aliceWs.close).toHaveBeenCalledWith(1008, "Removed");
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
|
||||
expect(
|
||||
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs(WS_OPEN);
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const openGuestWs = makeFakeWs(WS_OPEN);
|
||||
const closedGuestWs = makeFakeWs(WS_CLOSED);
|
||||
sessionManager.joinAsParticipant(id, openGuestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
sessionManager.joinAsParticipant(id, closedGuestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
sessionManager.broadcast(id, { type: "data", data: "hello" });
|
||||
|
||||
expect(ownerWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "data", data: "hello" }),
|
||||
);
|
||||
expect(openGuestWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "data", data: "hello" }),
|
||||
);
|
||||
expect(closedGuestWs.send).not.toHaveBeenCalled();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("broadcast does not throw if a socket's send throws", () => {
|
||||
const id = createConnectedSession();
|
||||
const throwingWs = makeFakeWs(WS_OPEN);
|
||||
(throwingWs.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("send failed");
|
||||
});
|
||||
sessionManager.attachWs(id, "owner-1", throwingWs);
|
||||
|
||||
expect(() =>
|
||||
sessionManager.broadcast(id, { type: "data", data: "x" }),
|
||||
).not.toThrow();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("broadcast is a no-op for a nonexistent session", () => {
|
||||
expect(() =>
|
||||
sessionManager.broadcast("does-not-exist", { type: "data" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("owner detach arms the idle timeout (existing behavior)", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
sessionManager.detachWs(id);
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.detachTimeout).not.toBeNull();
|
||||
expect(session?.lastDetachedAt).not.toBeNull();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
sessionManager.removeParticipant(id, guestWs);
|
||||
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session?.detachTimeout).toBeNull();
|
||||
expect(session?.participants.size).toBe(1);
|
||||
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("removeParticipant is a no-op when the ws belongs to the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
sessionManager.removeParticipant(id, ownerWs);
|
||||
|
||||
const session = sessionManager.getSession(id);
|
||||
expect(session?.participants.size).toBe(1);
|
||||
expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("destroySession cleans up all participants, not just the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
|
||||
expect(guestWs.send).toHaveBeenCalled();
|
||||
expect(sessionManager.getSession(id)).toBeNull();
|
||||
});
|
||||
|
||||
it("ownerEndSession notifies non-owner participants and destroys the session", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-write",
|
||||
});
|
||||
|
||||
sessionManager.ownerEndSession(id, "owner ended the session");
|
||||
|
||||
expect(guestWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
type: "sessionTerminatedByOwner",
|
||||
reason: "owner ended the session",
|
||||
}),
|
||||
);
|
||||
expect(sessionManager.getSession(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMessageAllowedForParticipant", () => {
|
||||
it("allows any message type for the owner or when there is no participant", () => {
|
||||
expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: true, permissionLevel: "read-write" },
|
||||
"resize",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops input from a read-only participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"input",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows input from a read-write non-owner participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-write" },
|
||||
"input",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows ping and disconnect for any non-owner participant", () => {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"ping",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-only" },
|
||||
"disconnect",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
|
||||
for (const type of [
|
||||
"resize",
|
||||
"totp_response",
|
||||
"password_response",
|
||||
"tmux_attach",
|
||||
"tmux_detach",
|
||||
"get_cwd",
|
||||
"vault_start_auth",
|
||||
"opkssh_start_auth",
|
||||
]) {
|
||||
expect(
|
||||
isMessageAllowedForParticipant(
|
||||
{ isOwner: false, permissionLevel: "read-write" },
|
||||
type,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTmuxAuthBehavior } from "../../../hosts/tmux/auth-utils.js";
|
||||
|
||||
describe("getTmuxAuthBehavior", () => {
|
||||
it("uses credentialless non-interactive authentication for Tailscale SSH", () => {
|
||||
expect(getTmuxAuthBehavior("tailscale")).toEqual({
|
||||
credentialless: true,
|
||||
tryKeyboard: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves keyboard-interactive fallback for none authentication", () => {
|
||||
expect(getTmuxAuthBehavior("none")).toEqual({
|
||||
credentialless: true,
|
||||
tryKeyboard: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat password authentication as credentialless", () => {
|
||||
expect(getTmuxAuthBehavior("password")).toEqual({
|
||||
credentialless: false,
|
||||
tryKeyboard: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { Client } from "ssh2";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectTmux,
|
||||
tmuxCommand,
|
||||
withTmuxPath,
|
||||
} from "../../../hosts/tmux/helper.js";
|
||||
|
||||
describe("tmux command path handling", () => {
|
||||
it("prepends all non-login tmux paths while preserving inherited PATH", () => {
|
||||
expect(withTmuxPath("command -v tmux")).toBe(
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; command -v tmux'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shell-escapes embedded single quotes in wrapped commands", () => {
|
||||
// Asserted as a string so the escaping rule is covered everywhere. The
|
||||
// round-trip below proves it against a real parser, but only where one
|
||||
// exists -- see the note there.
|
||||
expect(withTmuxPath(`printf '%s' "can't"`)).toBe(
|
||||
"/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:\"$PATH\"; export PATH; printf '\\''%s'\\'' \"can'\\''t\"'",
|
||||
);
|
||||
});
|
||||
|
||||
// /bin/sh is not on Windows, and Windows is a supported platform for the
|
||||
// desktop app -- contributors run `npm test` there. CI is ubuntu-only, so it
|
||||
// would never notice this failing.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"produces a command a real shell parses back to the original",
|
||||
() => {
|
||||
const command = withTmuxPath(`printf '%s' "can't"`);
|
||||
|
||||
expect(
|
||||
execFileSync("/bin/sh", ["-c", command], { encoding: "utf8" }),
|
||||
).toBe("can't");
|
||||
},
|
||||
);
|
||||
|
||||
it("runs every tmux invocation in UTF-8 mode through the path wrapper", () => {
|
||||
expect(tmuxCommand("list-sessions")).toBe(
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u list-sessions'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("detects tmux with the UTF-8 wrapper", async () => {
|
||||
const commands: string[] = [];
|
||||
const conn = {
|
||||
exec(command: string, callback: (error: null, stream: never) => void) {
|
||||
commands.push(command);
|
||||
const stream = new EventEmitter() as EventEmitter & {
|
||||
stderr: EventEmitter;
|
||||
};
|
||||
stream.stderr = new EventEmitter();
|
||||
callback(null, stream as never);
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (commands.length === 1) {
|
||||
stream.emit("data", Buffer.from("tmux 3.7b\n"));
|
||||
stream.emit("close", 0);
|
||||
return;
|
||||
}
|
||||
stream.emit("close", 1);
|
||||
});
|
||||
},
|
||||
} as unknown as Client;
|
||||
|
||||
await expect(detectTmux(conn)).resolves.toEqual({
|
||||
available: true,
|
||||
sessions: [],
|
||||
});
|
||||
expect(commands).toEqual([
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u -V'`,
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null'`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
SEP,
|
||||
parseSessions,
|
||||
parseWindows,
|
||||
parsePanes,
|
||||
parsePsOutput,
|
||||
parseGpuOutput,
|
||||
buildPaneMetrics,
|
||||
attachPanesToWindows,
|
||||
shellEscape,
|
||||
type ProcessInfo,
|
||||
type TmuxWindow,
|
||||
} from "../../../hosts/tmux/monitor-helpers.js";
|
||||
|
||||
function join(...fields: (string | number)[]): string {
|
||||
return fields.join(SEP);
|
||||
}
|
||||
|
||||
describe("parseSessions", () => {
|
||||
it("parses tmux list-sessions output", () => {
|
||||
const output = [
|
||||
join("training", 1760000000, 1760001000, 1),
|
||||
join("lab|with|pipes", 1760000500, 1760002000, 0),
|
||||
].join("\n");
|
||||
|
||||
const sessions = parseSessions(output);
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0]).toEqual({
|
||||
name: "training",
|
||||
created: 1760000000,
|
||||
lastActivity: 1760001000,
|
||||
attachedClients: 1,
|
||||
});
|
||||
// Session names containing "|" survive because SEP is a multi-char token
|
||||
expect(sessions[1].name).toBe("lab|with|pipes");
|
||||
expect(sessions[1].attachedClients).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty array for empty output", () => {
|
||||
expect(parseSessions("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWindows", () => {
|
||||
it("groups windows by session", () => {
|
||||
const output = [
|
||||
join("training", 0, 1, "vim"),
|
||||
join("training", 1, 0, "logs"),
|
||||
join("api", 0, 1, "server"),
|
||||
].join("\n");
|
||||
|
||||
const windows = parseWindows(output);
|
||||
expect(windows.get("training")).toHaveLength(2);
|
||||
expect(windows.get("training")![0]).toMatchObject({
|
||||
index: 0,
|
||||
name: "vim",
|
||||
active: true,
|
||||
});
|
||||
expect(windows.get("api")![0].name).toBe("server");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePanes", () => {
|
||||
it("parses full pane lines including free-text fields", () => {
|
||||
const output = join(
|
||||
"training",
|
||||
0,
|
||||
"%3",
|
||||
1,
|
||||
12345,
|
||||
1,
|
||||
120,
|
||||
40,
|
||||
"python",
|
||||
"/home/user/my|dir",
|
||||
"gpu01: train.py",
|
||||
);
|
||||
|
||||
const panes = parsePanes(output);
|
||||
expect(panes).toHaveLength(1);
|
||||
expect(panes[0]).toEqual({
|
||||
sessionName: "training",
|
||||
windowIndex: 0,
|
||||
id: "%3",
|
||||
index: 1,
|
||||
pid: 12345,
|
||||
active: true,
|
||||
width: 120,
|
||||
height: 40,
|
||||
command: "python",
|
||||
path: "/home/user/my|dir",
|
||||
title: "gpu01: train.py",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePsOutput", () => {
|
||||
it("parses ps -eo pid,ppid,pcpu,pmem,rss,comm output", () => {
|
||||
const output = [
|
||||
" 1 0 0.0 0.1 1234 systemd",
|
||||
"12345 1 2.5 1.0 50000 bash",
|
||||
"12400 12345 95.3 12.5 800000 python3",
|
||||
"garbage line",
|
||||
].join("\n");
|
||||
|
||||
const procs = parsePsOutput(output);
|
||||
expect(procs).toHaveLength(3);
|
||||
expect(procs[2]).toEqual({
|
||||
pid: 12400,
|
||||
ppid: 12345,
|
||||
cpu: 95.3,
|
||||
mem: 12.5,
|
||||
rss: 800000,
|
||||
comm: "python3",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGpuOutput", () => {
|
||||
it("parses nvidia-smi csv output and sums per pid", () => {
|
||||
const output = ["12400, 8000", "12400, 2000", "99999, 512"].join("\n");
|
||||
const gpu = parseGpuOutput(output);
|
||||
expect(gpu.get(12400)).toBe(10000);
|
||||
expect(gpu.get(99999)).toBe(512);
|
||||
});
|
||||
|
||||
it("handles empty output (no GPU)", () => {
|
||||
expect(parseGpuOutput("").size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPaneMetrics", () => {
|
||||
const panes = parsePanes(
|
||||
[
|
||||
join("training", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
|
||||
join("idle", 0, "%2", 0, 200, 1, 80, 24, "bash", "/", "t"),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const processes = parsePsOutput(
|
||||
[
|
||||
// pane %1: bash(100) -> python3(110) -> worker(111)
|
||||
" 100 1 0.1 0.1 4000 bash",
|
||||
" 110 100 90.0 10.0 700000 python3",
|
||||
" 111 110 9.5 2.0 100000 dataloader",
|
||||
// pane %2: bash(200) only
|
||||
" 200 1 0.0 0.1 4000 bash",
|
||||
// unrelated process
|
||||
" 300 1 50.0 5.0 200000 chrome",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
it("aggregates descendant trees per pane", () => {
|
||||
const metrics = buildPaneMetrics(panes, processes, new Map());
|
||||
const m1 = metrics.find((m) => m.paneId === "%1")!;
|
||||
expect(m1.processCount).toBe(3);
|
||||
expect(m1.cpuPercent).toBeCloseTo(99.6, 1);
|
||||
expect(m1.memRssKb).toBe(804000);
|
||||
expect(m1.topCommand).toBe("python3");
|
||||
|
||||
const m2 = metrics.find((m) => m.paneId === "%2")!;
|
||||
expect(m2.processCount).toBe(1);
|
||||
expect(m2.cpuPercent).toBe(0);
|
||||
// Unrelated process is never attributed
|
||||
expect(m2.memRssKb).toBe(4000);
|
||||
});
|
||||
|
||||
it("attributes GPU memory through the process tree", () => {
|
||||
const gpu = new Map([
|
||||
[110, 8000],
|
||||
[300, 4000],
|
||||
]);
|
||||
const metrics = buildPaneMetrics(panes, processes, gpu);
|
||||
expect(metrics.find((m) => m.paneId === "%1")!.gpuMemMb).toBe(8000);
|
||||
expect(metrics.find((m) => m.paneId === "%2")!.gpuMemMb).toBe(0);
|
||||
});
|
||||
|
||||
it("handles a pane whose pid is missing from ps output", () => {
|
||||
const orphan = parsePanes(
|
||||
join("gone", 0, "%9", 0, 99999, 0, 80, 24, "bash", "/", "t"),
|
||||
);
|
||||
const metrics = buildPaneMetrics(orphan, processes, new Map());
|
||||
expect(metrics[0].processCount).toBe(0);
|
||||
expect(metrics[0].cpuPercent).toBe(0);
|
||||
expect(metrics[0].topCommand).toBeNull();
|
||||
});
|
||||
|
||||
it("does not loop on cyclic ppid data", () => {
|
||||
const cyclic = parsePsOutput(
|
||||
[" 100 101 1.0 0.1 1000 a", " 101 100 1.0 0.1 1000 b"].join("\n"),
|
||||
);
|
||||
const pane = parsePanes(
|
||||
join("s", 0, "%1", 0, 100, 1, 80, 24, "a", "/", "t"),
|
||||
);
|
||||
const metrics = buildPaneMetrics(pane, cyclic, new Map());
|
||||
expect(metrics[0].processCount).toBe(2);
|
||||
});
|
||||
|
||||
it("aggregates a wide process tree without dropping children", () => {
|
||||
const childCount = 2_000;
|
||||
const wideTree: ProcessInfo[] = [
|
||||
{ pid: 1, ppid: 0, cpu: 0, mem: 0, rss: 1, comm: "bash" },
|
||||
...Array.from({ length: childCount }, (_, index) => ({
|
||||
pid: index + 2,
|
||||
ppid: 1,
|
||||
cpu: 0.1,
|
||||
mem: 0,
|
||||
rss: 1,
|
||||
comm: `worker-${index}`,
|
||||
})),
|
||||
];
|
||||
const pane = parsePanes(
|
||||
join("wide", 0, "%1", 0, 1, 1, 80, 24, "bash", "/", "t"),
|
||||
);
|
||||
|
||||
const [metrics] = buildPaneMetrics(pane, wideTree, new Map());
|
||||
expect(metrics.processCount).toBe(childCount + 1);
|
||||
expect(metrics.memRssKb).toBe(childCount + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachPanesToWindows", () => {
|
||||
it("places panes into their windows", () => {
|
||||
const windows = parseWindows(
|
||||
[join("s1", 0, 1, "main"), join("s1", 1, 0, "logs")].join("\n"),
|
||||
);
|
||||
const panes = parsePanes(
|
||||
[
|
||||
join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
|
||||
join("s1", 1, "%2", 0, 200, 0, 80, 24, "tail", "/", "t"),
|
||||
join("unknown", 5, "%3", 0, 300, 0, 80, 24, "bash", "/", "t"),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
attachPanesToWindows(windows, panes);
|
||||
expect(windows.get("s1")![0].panes).toHaveLength(1);
|
||||
expect(windows.get("s1")![0].panes[0].id).toBe("%1");
|
||||
expect(windows.get("s1")![1].panes[0].id).toBe("%2");
|
||||
});
|
||||
|
||||
it("preserves first-match behavior for duplicate window indexes", () => {
|
||||
const first: TmuxWindow = {
|
||||
index: 0,
|
||||
name: "first",
|
||||
active: true,
|
||||
panes: [],
|
||||
};
|
||||
const duplicate: TmuxWindow = {
|
||||
index: 0,
|
||||
name: "duplicate",
|
||||
active: false,
|
||||
panes: [],
|
||||
};
|
||||
const windows = new Map([["s1", [first, duplicate]]]);
|
||||
const panes = parsePanes(
|
||||
join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
|
||||
);
|
||||
|
||||
attachPanesToWindows(windows, panes);
|
||||
expect(first.panes).toHaveLength(1);
|
||||
expect(duplicate.panes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shellEscape", () => {
|
||||
it("wraps in single quotes and escapes embedded quotes", () => {
|
||||
expect(shellEscape("simple")).toBe("'simple'");
|
||||
expect(shellEscape("it's")).toBe("'it'\\''s'");
|
||||
expect(shellEscape("$(rm -rf /)")).toBe("'$(rm -rf /)'");
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const canAccessHost = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: { getInstance: () => ({ canAccessHost }) },
|
||||
}));
|
||||
|
||||
/**
|
||||
* /ssh/tunnel/disconnect and /ssh/tunnel/cancel are addressed by tunnel NAME.
|
||||
* Their ownership check used to run only inside
|
||||
* `if (config && config.sourceHostId)`, and web endpoint tunnels are
|
||||
* deliberately kept out of tunnelConfigs -- so `config` was always undefined
|
||||
* for them and the check never ran.
|
||||
*
|
||||
* Host ids are small sequential integers and endpoint ids are client-supplied
|
||||
* with no server-side randomness, so `web:{hostId}:{endpointId}` is guessable.
|
||||
* That let any authenticated user force-close another user's live tunnel, and
|
||||
* manualDisconnects then made the victim's own re-open fail for several
|
||||
* seconds.
|
||||
*/
|
||||
describe("authorizeTunnelAction", () => {
|
||||
beforeEach(() => {
|
||||
canAccessHost.mockReset();
|
||||
});
|
||||
|
||||
it("checks the host encoded in a reserved name when no config exists", async () => {
|
||||
canAccessHost.mockResolvedValue({ hasAccess: true });
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
const result = await authorizeTunnelAction("u1", "web:7:e1", undefined);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(canAccessHost).toHaveBeenCalledWith("u1", 7, "connect");
|
||||
});
|
||||
|
||||
it("denies a reserved name whose host the user cannot access", async () => {
|
||||
canAccessHost.mockResolvedValue({ hasAccess: false });
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
expect(
|
||||
(await authorizeTunnelAction("u1", "web:7:e1", undefined)).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on a reserved name it cannot parse", async () => {
|
||||
// Never fall through to the unchecked path: an unparseable reserved name
|
||||
// means "cannot verify ownership", which must deny.
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
for (const name of ["web:", "web:abc:e1", "web:0:e1"]) {
|
||||
expect((await authorizeTunnelAction("u1", name, undefined)).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
expect(canAccessHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still checks a registered config's host id", async () => {
|
||||
canAccessHost.mockResolvedValue({ hasAccess: true });
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
await authorizeTunnelAction("u1", "my-tunnel", { sourceHostId: 42 });
|
||||
expect(canAccessHost).toHaveBeenCalledWith("u1", 42, "connect");
|
||||
});
|
||||
|
||||
it("leaves an ordinary unregistered name allowed, as before", async () => {
|
||||
// Pre-existing behaviour for non-reserved names, preserved deliberately:
|
||||
// tightening it is a separate change with its own blast radius.
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
expect(
|
||||
(await authorizeTunnelAction("u1", "my-tunnel", undefined)).allowed,
|
||||
).toBe(true);
|
||||
expect(canAccessHost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RESERVED_TUNNEL_NAME_PREFIX,
|
||||
buildWebEndpointTunnelName,
|
||||
isReservedTunnelName,
|
||||
parseReservedTunnelName,
|
||||
} from "../../hosts/tunnel/utils.js";
|
||||
|
||||
describe("reserved tunnel names", () => {
|
||||
it("builds a name from a host id and endpoint id", () => {
|
||||
expect(buildWebEndpointTunnelName(7, "e1")).toBe("web:7:e1");
|
||||
});
|
||||
|
||||
it("recognises its own names and nothing else", () => {
|
||||
expect(isReservedTunnelName(buildWebEndpointTunnelName(7, "e1"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isReservedTunnelName("my-tunnel")).toBe(false);
|
||||
expect(isReservedTunnelName("website")).toBe(false);
|
||||
expect(isReservedTunnelName(RESERVED_TUNNEL_NAME_PREFIX)).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips through parse", () => {
|
||||
expect(
|
||||
parseReservedTunnelName(buildWebEndpointTunnelName(7, "e1")),
|
||||
).toEqual({
|
||||
hostId: 7,
|
||||
endpointId: "e1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an endpoint id containing colons intact", () => {
|
||||
// Endpoint ids are client-supplied strings with no character restriction,
|
||||
// so splitting on the LAST colon would corrupt them. The host id segment
|
||||
// is digits only, so splitting on the first colon after it is safe.
|
||||
expect(parseReservedTunnelName("web:7:a:b:c")).toEqual({
|
||||
hostId: 7,
|
||||
endpointId: "a:b:c",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for anything it cannot verify", () => {
|
||||
// Callers use this for an ownership check, so null must mean "cannot
|
||||
// verify" and they must fail closed. Never return a partial guess.
|
||||
for (const name of [
|
||||
"my-tunnel",
|
||||
"web:",
|
||||
"web:7",
|
||||
"web::e1",
|
||||
"web:abc:e1",
|
||||
"web:-1:e1",
|
||||
"web:0:e1",
|
||||
"web:7:",
|
||||
"web:7.5:e1",
|
||||
]) {
|
||||
expect(parseReservedTunnelName(name)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveC2SSourceHostId } from "../../../hosts/tunnel/c2s-relay.js";
|
||||
|
||||
describe("resolveC2SSourceHostId", () => {
|
||||
it("uses the remote row matching the stable sync id", async () => {
|
||||
const findBySyncId = vi.fn().mockResolvedValue(42);
|
||||
|
||||
await expect(
|
||||
resolveC2SSourceHostId(
|
||||
{ sourceHostId: 7, sourceHostSyncId: "host-sync-id" },
|
||||
findBySyncId,
|
||||
),
|
||||
).resolves.toBe(42);
|
||||
expect(findBySyncId).toHaveBeenCalledWith("host-sync-id");
|
||||
});
|
||||
|
||||
it("keeps legacy local ids when no sync id is available", async () => {
|
||||
await expect(
|
||||
resolveC2SSourceHostId({ sourceHostId: 7 }, vi.fn()),
|
||||
).resolves.toBe(7);
|
||||
});
|
||||
|
||||
it("does not silently fall back to a mismatched id", async () => {
|
||||
await expect(
|
||||
resolveC2SSourceHostId(
|
||||
{ sourceHostId: 7, sourceHostSyncId: "missing" },
|
||||
vi.fn().mockResolvedValue(null),
|
||||
),
|
||||
).rejects.toThrow("not found on the remote server");
|
||||
});
|
||||
});
|
||||
@@ -1,268 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
|
||||
import { execFileSync } from "child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import ssh2Pkg from "ssh2";
|
||||
import {
|
||||
generateEphemeralKeyPair,
|
||||
parseCertValidBefore,
|
||||
startVaultOidc,
|
||||
completeVaultOidc,
|
||||
signWithVault,
|
||||
type VaultProfileConfig,
|
||||
} from "../../hosts/vault-signer-core.js";
|
||||
|
||||
const { utils: ssh2Utils } = ssh2Pkg;
|
||||
|
||||
describe("generateEphemeralKeyPair", () => {
|
||||
for (const keyType of [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ssh-rsa",
|
||||
] as const) {
|
||||
it(`generates a parseable ${keyType} keypair`, () => {
|
||||
const pair = generateEphemeralKeyPair(keyType);
|
||||
expect(pair.privateKey).toContain("BEGIN OPENSSH PRIVATE KEY");
|
||||
expect(pair.publicKey.split(/\s+/)[0]).toBe(keyType);
|
||||
|
||||
// Both halves must be parseable by the same library that signs/connects.
|
||||
const priv = ssh2Utils.parseKey(pair.privateKey);
|
||||
expect(priv instanceof Error).toBe(false);
|
||||
const pub = ssh2Utils.parseKey(pair.publicKey);
|
||||
expect(pub instanceof Error).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it("defaults to ed25519 for unknown key types", () => {
|
||||
const pair = generateEphemeralKeyPair("nonsense");
|
||||
expect(pair.publicKey.startsWith("ssh-ed25519")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCertValidBefore", () => {
|
||||
let cert = "";
|
||||
let signedAt = 0;
|
||||
let haveSshKeygen = true;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "vault-cert-test-"));
|
||||
try {
|
||||
execFileSync("ssh-keygen", [
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-f",
|
||||
`${dir}/ca`,
|
||||
"-N",
|
||||
"",
|
||||
"-q",
|
||||
]);
|
||||
execFileSync("ssh-keygen", [
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-f",
|
||||
`${dir}/user`,
|
||||
"-N",
|
||||
"",
|
||||
"-q",
|
||||
]);
|
||||
signedAt = Math.floor(Date.now() / 1000);
|
||||
execFileSync("ssh-keygen", [
|
||||
"-s",
|
||||
`${dir}/ca`,
|
||||
"-I",
|
||||
"test-id",
|
||||
"-n",
|
||||
"root",
|
||||
"-V",
|
||||
"+60m",
|
||||
`${dir}/user.pub`,
|
||||
]);
|
||||
cert = readFileSync(`${dir}/user-cert.pub`, "utf8").trim();
|
||||
} catch {
|
||||
haveSshKeygen = false;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reads valid_before from a real ssh-keygen certificate", () => {
|
||||
if (!haveSshKeygen) {
|
||||
console.warn("ssh-keygen unavailable; skipping real-cert parse test");
|
||||
return;
|
||||
}
|
||||
const validBefore = parseCertValidBefore(cert);
|
||||
// -V +60m => valid_before is roughly signedAt + 3600 (start rounds to minute)
|
||||
expect(validBefore).toBeGreaterThan(signedAt + 3300);
|
||||
expect(validBefore).toBeLessThan(signedAt + 3900);
|
||||
});
|
||||
|
||||
it("returns 0 for malformed input", () => {
|
||||
expect(parseCertValidBefore("")).toBe(0);
|
||||
expect(parseCertValidBefore("not-a-cert")).toBe(0);
|
||||
expect(parseCertValidBefore("ssh-ed25519 AAAAnotbase64!!")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vault HTTP flow (mocked fetch)", () => {
|
||||
const profile: VaultProfileConfig = {
|
||||
id: 1,
|
||||
vaultAddr: "https://vault.example.com:8200/",
|
||||
vaultNamespace: "team-a",
|
||||
oidcMount: "oidc",
|
||||
oidcRole: "developer",
|
||||
sshMount: "ssh-client-signer",
|
||||
sshRole: "my-role",
|
||||
validPrincipals: "root,deploy",
|
||||
keyType: "ssh-ed25519",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetch(
|
||||
impl: (
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
) => { status?: number; body: unknown },
|
||||
) {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
const { status = 200, body } = impl(url, init);
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as Response;
|
||||
}),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
it("startVaultOidc posts auth_url and extracts state", async () => {
|
||||
const calls = mockFetch(() => ({
|
||||
body: {
|
||||
data: {
|
||||
auth_url:
|
||||
"https://idp.example.com/authorize?client_id=x&state=ST-abc123&nonce=n",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await startVaultOidc(
|
||||
profile,
|
||||
"https://termix/vault/oidc/callback",
|
||||
);
|
||||
|
||||
expect(result.state).toBe("ST-abc123");
|
||||
expect(result.clientNonce).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe(
|
||||
"https://vault.example.com:8200/v1/auth/oidc/oidc/auth_url",
|
||||
);
|
||||
expect(calls[0].init.method).toBe("POST");
|
||||
const headers = calls[0].init.headers as Record<string, string>;
|
||||
expect(headers["X-Vault-Namespace"]).toBe("team-a");
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body).toMatchObject({
|
||||
role: "developer",
|
||||
redirect_uri: "https://termix/vault/oidc/callback",
|
||||
});
|
||||
expect(body.client_nonce).toBe(result.clientNonce);
|
||||
});
|
||||
|
||||
it("completeVaultOidc returns the client token", async () => {
|
||||
const calls = mockFetch(() => ({
|
||||
body: { auth: { client_token: "hvs.TESTTOKEN" } },
|
||||
}));
|
||||
|
||||
const token = await completeVaultOidc(profile, {
|
||||
state: "ST-abc123",
|
||||
code: "auth-code",
|
||||
clientNonce: "nonce123",
|
||||
});
|
||||
|
||||
expect(token).toBe("hvs.TESTTOKEN");
|
||||
const url = new URL(calls[0].url);
|
||||
expect(url.pathname).toBe("/v1/auth/oidc/oidc/callback");
|
||||
expect(url.searchParams.get("state")).toBe("ST-abc123");
|
||||
expect(url.searchParams.get("code")).toBe("auth-code");
|
||||
expect(url.searchParams.get("client_nonce")).toBe("nonce123");
|
||||
expect(calls[0].init.method).toBe("GET");
|
||||
});
|
||||
|
||||
it("signWithVault posts the public key and returns signed_key", async () => {
|
||||
const calls = mockFetch(() => ({
|
||||
body: {
|
||||
data: { signed_key: "ssh-ed25519-cert-v01@openssh.com AAAAcert" },
|
||||
},
|
||||
}));
|
||||
|
||||
const cert = await signWithVault(
|
||||
profile,
|
||||
"hvs.TESTTOKEN",
|
||||
"ssh-ed25519 AAAApub comment",
|
||||
);
|
||||
|
||||
expect(cert).toBe("ssh-ed25519-cert-v01@openssh.com AAAAcert");
|
||||
expect(calls[0].url).toBe(
|
||||
"https://vault.example.com:8200/v1/ssh-client-signer/sign/my-role",
|
||||
);
|
||||
const headers = calls[0].init.headers as Record<string, string>;
|
||||
expect(headers["X-Vault-Token"]).toBe("hvs.TESTTOKEN");
|
||||
expect(headers["X-Vault-Namespace"]).toBe("team-a");
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body).toMatchObject({
|
||||
public_key: "ssh-ed25519 AAAApub comment",
|
||||
cert_type: "user",
|
||||
valid_principals: "root,deploy",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces Vault error messages", async () => {
|
||||
mockFetch(() => ({
|
||||
status: 400,
|
||||
body: { errors: ["role not found", "permission denied"] },
|
||||
}));
|
||||
|
||||
await expect(
|
||||
signWithVault(profile, "tok", "ssh-ed25519 AAAA"),
|
||||
).rejects.toThrow(/role not found; permission denied/);
|
||||
});
|
||||
});
|
||||
|
||||
// Live integration against a real Vault (e.g. `vault server -dev` in Docker).
|
||||
// Runs only when VAULT_ADDR + VAULT_TOKEN are set and the SSH signer mount
|
||||
// (VAULT_SSH_MOUNT/VAULT_SSH_ROLE) has been configured by the test harness.
|
||||
describe("Vault live signing", () => {
|
||||
const addr = process.env.VAULT_ADDR;
|
||||
const token = process.env.VAULT_TOKEN;
|
||||
const run = !!addr && !!token;
|
||||
|
||||
it.skipIf(!run)("signs an ephemeral key against a real Vault", async () => {
|
||||
const profile: VaultProfileConfig = {
|
||||
id: 99,
|
||||
vaultAddr: addr!,
|
||||
sshMount: process.env.VAULT_SSH_MOUNT || "ssh-client-signer",
|
||||
sshRole: process.env.VAULT_SSH_ROLE || "my-role",
|
||||
validPrincipals: "root",
|
||||
keyType: "ssh-ed25519",
|
||||
};
|
||||
|
||||
const pair = generateEphemeralKeyPair(profile.keyType);
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const cert = await signWithVault(profile, token!, pair.publicKey);
|
||||
|
||||
expect(cert).toMatch(/-cert-v01@openssh\.com /);
|
||||
// The signed cert must parse with the same library used to connect.
|
||||
const parsed = ssh2Utils.parseKey(cert);
|
||||
expect(parsed instanceof Error).toBe(false);
|
||||
|
||||
const validBefore = parseCertValidBefore(cert);
|
||||
expect(validBefore).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
@@ -1,326 +0,0 @@
|
||||
import type express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveHostById = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({ resolveHostById }));
|
||||
|
||||
const manager = vi.hoisted(() => ({
|
||||
activeTunnelRuntimes: new Map<string, unknown>(),
|
||||
connectionStatus: new Map<string, unknown>(),
|
||||
tunnelConnecting: new Set<string>(),
|
||||
connectSSHTunnel: vi.fn(),
|
||||
cleanupTunnelResources: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("../../hosts/tunnel/manager.js", () => manager);
|
||||
|
||||
const forwardOut = vi.hoisted(() => vi.fn(async () => ({ end: vi.fn() })));
|
||||
vi.mock("../../hosts/tunnel/ssh-primitives.js", () => ({ forwardOut }));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
|
||||
}));
|
||||
|
||||
function response() {
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return res as unknown as express.Response & {
|
||||
statusCode: number;
|
||||
body: { port?: number; error?: string };
|
||||
};
|
||||
}
|
||||
|
||||
function request(body: unknown, userId: string | undefined = "u1") {
|
||||
return { body, userId } as unknown as express.Request;
|
||||
}
|
||||
|
||||
const endpoint = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "http",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "tunnel",
|
||||
render: "embedded",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function host(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 7,
|
||||
userId: "u1",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "pw",
|
||||
enableWebUi: true,
|
||||
webUiConfig: JSON.stringify({ endpoints: [endpoint()] }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Makes connectSSHTunnel behave like a successful open. */
|
||||
function tunnelConnectsOn(port: number) {
|
||||
manager.connectSSHTunnel.mockImplementation(
|
||||
async (config: { name: string }) => {
|
||||
manager.activeTunnelRuntimes.set(config.name, {
|
||||
bindPort: port,
|
||||
sourceClient: {},
|
||||
close: vi.fn(),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
manager.activeTunnelRuntimes.clear();
|
||||
manager.connectionStatus.clear();
|
||||
manager.tunnelConnecting.clear();
|
||||
manager.connectSSHTunnel.mockReset();
|
||||
manager.cleanupTunnelResources.mockClear();
|
||||
forwardOut.mockClear();
|
||||
forwardOut.mockResolvedValue({ end: vi.fn() });
|
||||
resolveHostById.mockReset();
|
||||
resolveHostById.mockResolvedValue(host());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("POST /tunnel/web-endpoint/open", () => {
|
||||
it("refuses a host the user cannot resolve, without opening anything", async () => {
|
||||
resolveHostById.mockResolvedValue(null);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
|
||||
// Reachable via a bulk update that sends only { webUiConfig } while the
|
||||
// stored enable_web_ui is still 0. The UI reads as off in that state.
|
||||
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an endpoint id that matches nothing", async () => {
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "nope" }),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns the real bound port", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.port).toBe(41234);
|
||||
});
|
||||
|
||||
it("selects the local-forward path via endpointHost, not targetHost", async () => {
|
||||
// connectSSHTunnel routes on isSingleHostTunnel, which keys on
|
||||
// endpointHost === "127.0.0.1". Setting only targetHost would silently
|
||||
// select a different strategy and the forward would never bind locally.
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
|
||||
const config = manager.connectSSHTunnel.mock.calls[0][0];
|
||||
expect(config.endpointHost).toBe("127.0.0.1");
|
||||
expect(config.mode).toBe("local");
|
||||
expect(config.endpointPort).toBe(8006);
|
||||
// Carries the source SSH identity -- connectSSHTunnel builds its client
|
||||
// from these, and reads sourceUserId specifically.
|
||||
expect(config.sourceIP).toBe("10.0.0.5");
|
||||
expect(config.sourceUserId).toBe("u1");
|
||||
});
|
||||
|
||||
it("binds loopback by default and the endpoint's choice when given", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
expect(manager.connectSSHTunnel.mock.calls[0][0].bindHost).toBe(
|
||||
"127.0.0.1",
|
||||
);
|
||||
|
||||
manager.activeTunnelRuntimes.clear();
|
||||
resolveHostById.mockResolvedValue(
|
||||
host({
|
||||
webUiConfig: JSON.stringify({
|
||||
endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
const config = manager.connectSSHTunnel.mock.calls[1][0];
|
||||
expect(config.bindHost).toBe("0.0.0.0");
|
||||
expect(config.sourcePort).toBe(38080);
|
||||
});
|
||||
|
||||
it("reuses a live tunnel instead of opening a second one", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
const first = response();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
first,
|
||||
);
|
||||
const second = response();
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
second,
|
||||
);
|
||||
|
||||
expect(second.body.port).toBe(41234);
|
||||
expect(manager.connectSSHTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reopens when the endpoint's target changed under a live tunnel", async () => {
|
||||
tunnelConnectsOn(41234);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
await handleWebEndpointOpen(
|
||||
request({ hostId: 7, endpointId: "e1" }),
|
||||
response(),
|
||||
);
|
||||
|
||||
// Same id, different port: the live forward now points at the wrong place.
|
||||
resolveHostById.mockResolvedValue(
|
||||
host({
|
||||
webUiConfig: JSON.stringify({ endpoints: [endpoint({ port: 9000 })] }),
|
||||
}),
|
||||
);
|
||||
tunnelConnectsOn(51234);
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
|
||||
"web:7:e1",
|
||||
true,
|
||||
);
|
||||
expect(res.body.port).toBe(51234);
|
||||
});
|
||||
|
||||
it("reports the target port being closed rather than returning a dead 200", async () => {
|
||||
// The likeliest real error: the listener binds fine, but nothing is
|
||||
// serving on the endpoint's port. Without the probe this returns 200 and
|
||||
// the user gets a blank frame with no message.
|
||||
tunnelConnectsOn(41234);
|
||||
forwardOut.mockRejectedValue(
|
||||
new Error("Channel open failure: connect failed"),
|
||||
);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(String(res.body.error)).toMatch(/connect failed/);
|
||||
// Nothing half-built is left registered.
|
||||
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
|
||||
"web:7:e1",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces a connection failure reported only over the status map", async () => {
|
||||
// connectSSHTunnel never rejects and resolves before the SSH handshake, so
|
||||
// without waitForTunnelSettled this path would read as success.
|
||||
manager.connectSSHTunnel.mockImplementation(
|
||||
async (config: { name: string }) => {
|
||||
manager.connectionStatus.set(config.name, {
|
||||
connected: false,
|
||||
reason: "All authentication methods failed",
|
||||
});
|
||||
},
|
||||
);
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(String(res.body.error)).toMatch(/authentication methods failed/);
|
||||
});
|
||||
|
||||
it("rejects a malformed request before touching the database", async () => {
|
||||
const { handleWebEndpointOpen } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
for (const body of [
|
||||
{},
|
||||
{ hostId: "7", endpointId: "e1" },
|
||||
{ hostId: 0, endpointId: "e1" },
|
||||
]) {
|
||||
const res = response();
|
||||
await handleWebEndpointOpen(request(body), res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
}
|
||||
expect(resolveHostById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The client resolves "/tunnel/web-endpoint/open" against a baseURL that
|
||||
* already ends in /ssh, and nginx proxies /ssh through with the path intact --
|
||||
* so the server must register the FULL "/ssh/tunnel/..." path, as every route
|
||||
* in routes.ts does. Registering the unprefixed form 404s every call, and no
|
||||
* handler unit test notices because they call the handler directly.
|
||||
*
|
||||
* This was caught only by opening a real tunnel against a deployed build.
|
||||
*/
|
||||
describe("route registration", () => {
|
||||
it("registers the /ssh-prefixed path the client actually calls", async () => {
|
||||
const posts: string[] = [];
|
||||
const app = {
|
||||
post: (path: string) => {
|
||||
posts.push(path);
|
||||
},
|
||||
} as unknown as express.Express;
|
||||
|
||||
const { registerWebEndpointRoutes } =
|
||||
await import("../../hosts/tunnel/web-endpoint-routes.js");
|
||||
registerWebEndpointRoutes(app);
|
||||
|
||||
expect(posts).toContain("/ssh/tunnel/web-endpoint/open");
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Exercises establishDirectTunnel's local branch against a REAL TCP listener.
|
||||
* The two behaviours here are the ones a mocked test cannot show:
|
||||
*
|
||||
* - under sourcePort 0 the kernel assigns the port, so the runtime has to
|
||||
* record what it actually bound rather than the 0 it was asked for; and
|
||||
* - an idle tunnel closes itself and REMOVES its runtime entry, so nothing
|
||||
* keeps handing out a port whose listener is gone.
|
||||
*
|
||||
* The SSH half is stubbed -- these behaviours live entirely in the listener
|
||||
* and the runtime bookkeeping.
|
||||
*/
|
||||
const sshPrimitives = vi.hoisted(() => ({
|
||||
forwardOut: vi.fn(async () => {
|
||||
throw new Error("not used in these tests");
|
||||
}),
|
||||
pipeTunnelStreams: vi.fn(),
|
||||
handleSocks5Connect: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../hosts/tunnel/ssh-primitives.js", () => sshPrimitives);
|
||||
|
||||
function tunnelConfig(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "web:7:e1",
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
bindHost: "127.0.0.1",
|
||||
sourcePort: 0,
|
||||
endpointHost: "127.0.0.1",
|
||||
endpointPort: 9,
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
const fakeClient = () => ({ on: vi.fn(), end: vi.fn() }) as never;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("establishDirectTunnel records the port it actually bound", () => {
|
||||
it("reports the kernel-assigned port, never the requested 0", async () => {
|
||||
const manager = await import("../../hosts/tunnel/manager.js");
|
||||
await manager.establishDirectTunnel(fakeClient(), tunnelConfig());
|
||||
|
||||
const runtime = manager.activeTunnelRuntimes.get("web:7:e1");
|
||||
expect(runtime).toBeDefined();
|
||||
// The whole point: 0 means "pick one for me", so a runtime advertising 0
|
||||
// would make every caller that reuses the port fail.
|
||||
expect(runtime?.bindPort).toBeGreaterThan(0);
|
||||
|
||||
// And it is genuinely listening there.
|
||||
const port = runtime!.bindPort as number;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, "127.0.0.1", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", reject);
|
||||
});
|
||||
|
||||
runtime?.close();
|
||||
manager.activeTunnelRuntimes.delete("web:7:e1");
|
||||
});
|
||||
|
||||
it("honours a fixed source port when one is asked for", async () => {
|
||||
const manager = await import("../../hosts/tunnel/manager.js");
|
||||
// Borrow a free port, release it, then ask for it explicitly.
|
||||
const probe = net.createServer();
|
||||
const wanted = await new Promise<number>((resolve) => {
|
||||
probe.listen(0, "127.0.0.1", () =>
|
||||
resolve((probe.address() as { port: number }).port),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
||||
|
||||
await manager.establishDirectTunnel(
|
||||
fakeClient(),
|
||||
tunnelConfig({ name: "web:7:fixed", sourcePort: wanted }),
|
||||
);
|
||||
const runtime = manager.activeTunnelRuntimes.get("web:7:fixed");
|
||||
expect(runtime?.bindPort).toBe(wanted);
|
||||
|
||||
runtime?.close();
|
||||
manager.activeTunnelRuntimes.delete("web:7:fixed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("idle close", () => {
|
||||
it("removes the runtime entry, not just the listener", async () => {
|
||||
// Real timers deliberately: establishDirectTunnel awaits real socket I/O,
|
||||
// and mixing fake timers with that is a known route to a hanging test.
|
||||
const manager = await import("../../hosts/tunnel/manager.js");
|
||||
await manager.establishDirectTunnel(
|
||||
fakeClient(),
|
||||
tunnelConfig({ name: "web:7:idle", idleTimeoutMs: 60 }),
|
||||
);
|
||||
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(true);
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
// Deleting the entry is the assertion. close() alone would stop the
|
||||
// listener but leave the entry behind, so the open route would keep
|
||||
// returning a port nothing is listening on.
|
||||
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(false);
|
||||
},
|
||||
{ timeout: 4000, interval: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a tunnel with no idle timeout running", async () => {
|
||||
// Without this, an idle-close bug that fires unconditionally would still
|
||||
// pass the test above.
|
||||
const manager = await import("../../hosts/tunnel/manager.js");
|
||||
await manager.establishDirectTunnel(
|
||||
fakeClient(),
|
||||
tunnelConfig({ name: "web:7:forever" }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(manager.activeTunnelRuntimes.has("web:7:forever")).toBe(true);
|
||||
|
||||
manager.activeTunnelRuntimes.get("web:7:forever")?.close();
|
||||
manager.activeTunnelRuntimes.delete("web:7:forever");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user