mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06: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
@@ -0,0 +1,143 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPluginAssetsRouter,
|
||||
describePluginFrontend,
|
||||
resolvePluginAsset,
|
||||
} from "../../plugins/assets.js";
|
||||
|
||||
let root = "";
|
||||
let plugin: { id: string; dir: string; manifest: never };
|
||||
let server: Server;
|
||||
let baseUrl = "";
|
||||
|
||||
function write(relative: string, content: string) {
|
||||
const file = path.join(root, "gizmo", relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-assets-"));
|
||||
write("dist/frontend.js", "export function activate() {}");
|
||||
write("dist/frontend.css", ".gizmo{}");
|
||||
write("dist/backend.js", "secret()");
|
||||
write("dist/notes.txt", "text");
|
||||
write("locales/en.json", "{}");
|
||||
write("locales/translated/de_DE.json", "{}");
|
||||
write("manifest.json", "{}");
|
||||
fs.writeFileSync(path.join(root, "outside.js"), "nope");
|
||||
plugin = {
|
||||
id: "gizmo",
|
||||
dir: path.join(root, "gizmo"),
|
||||
manifest: { id: "gizmo", version: "1.0.0" } as never,
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
createPluginAssetsRouter((id) => (id === "gizmo" ? plugin : undefined)),
|
||||
);
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("describePluginFrontend", () => {
|
||||
it("reports the bundle, stylesheet, version and locales", () => {
|
||||
const info = describePluginFrontend(plugin);
|
||||
expect(info.frontend).toBe(true);
|
||||
expect(info.css).toBe(true);
|
||||
expect(info.assetVersion).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(info.locales).toEqual(["de_DE", "en"]);
|
||||
});
|
||||
|
||||
it("changes the version when only the stylesheet changes", () => {
|
||||
const before = describePluginFrontend(plugin).assetVersion;
|
||||
const css = path.join(root, "gizmo", "dist", "frontend.css");
|
||||
fs.writeFileSync(css, ".gizmo{color:red}");
|
||||
try {
|
||||
expect(describePluginFrontend(plugin).assetVersion).not.toBe(before);
|
||||
} finally {
|
||||
fs.writeFileSync(css, ".gizmo{}");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports nothing for an unknown plugin", () => {
|
||||
expect(describePluginFrontend(undefined)).toEqual({
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePluginAsset", () => {
|
||||
it("serves the bundle and locale files", () => {
|
||||
expect(resolvePluginAsset(plugin, "frontend.js")).toBe(
|
||||
path.join(root, "gizmo", "dist", "frontend.js"),
|
||||
);
|
||||
expect(resolvePluginAsset(plugin, "locales/translated/de_DE.json")).toBe(
|
||||
path.join(root, "gizmo", "locales", "translated", "de_DE.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("never serves the backend bundle", () => {
|
||||
expect(resolvePluginAsset(plugin, "backend.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses paths that escape the plugin's directories", () => {
|
||||
expect(resolvePluginAsset(plugin, "../manifest.json")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "../../outside.js")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "locales/../dist/backend.js")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "..\\manifest.json")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses file types it does not serve", () => {
|
||||
expect(resolvePluginAsset(plugin, "notes.txt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /plugin-assets", () => {
|
||||
it("serves a bundle as a module any origin can load", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("text/javascript");
|
||||
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
||||
expect(res.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(await res.text()).toContain("activate");
|
||||
});
|
||||
|
||||
it("caches a versioned request for good", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js?v=abc`);
|
||||
expect(res.headers.get("cache-control")).toContain("immutable");
|
||||
});
|
||||
|
||||
it("404s an unknown plugin", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/nobody/frontend.js`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s the backend bundle and traversal attempts", async () => {
|
||||
for (const target of [
|
||||
"gizmo/backend.js",
|
||||
"gizmo/%2e%2e/manifest.json",
|
||||
"gizmo/..%2f..%2foutside.js",
|
||||
]) {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/${target}`);
|
||||
expect(res.status, target).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Boots core the way starter.ts does, against a real SQLite database that
|
||||
* starts in 2.8 shape, with the plugin loader pointed at the built bundles in
|
||||
* dist/plugins rather than source. Shared by the boot and upgrade tests.
|
||||
*
|
||||
* Needs `npm run build` (or build:sdk and build:plugins) first.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { DATA_DIR_TOKEN } from "../fixtures/upgrade/rows.js";
|
||||
import { remoteTestDialect, seedRemote28 } from "./remote-seed.js";
|
||||
|
||||
export const REPO_ROOT = path.resolve(__dirname, "../../../..");
|
||||
export const BUILT_PLUGINS_DIR = path.join(REPO_ROOT, "dist", "plugins");
|
||||
const SCHEMA_28 = path.join(__dirname, "../fixtures/sqlite-2.8-schema.sql");
|
||||
/** The committed 2.8 install: db.sqlite, the fixture.env it was keyed with and its files. */
|
||||
export const UPGRADE_FIXTURE_DIR = path.join(__dirname, "../fixtures/upgrade");
|
||||
|
||||
/** Every plugin in the repo, by folder. */
|
||||
export function sourcePluginIds(): string[] {
|
||||
const root = path.join(REPO_ROOT, "plugins");
|
||||
return fs
|
||||
.readdirSync(root, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(root, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Throws with the fix when dist/plugins is missing a plugin. */
|
||||
export function assertPluginsBuilt(): void {
|
||||
const missing = sourcePluginIds().filter(
|
||||
(id) =>
|
||||
!fs.existsSync(path.join(BUILT_PLUGINS_DIR, id, "dist", "backend.js")),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`dist/plugins is missing ${missing.join(", ")}. Run npm run build first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function builtManifest(id: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(BUILT_PLUGINS_DIR, id, "manifest.json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** A 2.8 database in memory, with a helper that ignores unknown columns. */
|
||||
export function create28Database() {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.exec(fs.readFileSync(SCHEMA_28, "utf8"));
|
||||
const columns = new Map<string, Set<string>>();
|
||||
const insert = (table: string, row: Row): number => {
|
||||
let known = columns.get(table);
|
||||
if (!known) {
|
||||
known = new Set(
|
||||
(
|
||||
sqlite.prepare(`PRAGMA table_info("${table}")`).all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((column) => column.name),
|
||||
);
|
||||
columns.set(table, known);
|
||||
}
|
||||
const entries = Object.entries(row).filter(([key]) => known!.has(key));
|
||||
const sql = `INSERT INTO "${table}" (${entries
|
||||
.map(([key]) => `"${key}"`)
|
||||
.join(", ")}) VALUES (${entries.map(() => "?").join(", ")})`;
|
||||
const values = entries.map(([, value]) =>
|
||||
typeof value === "boolean"
|
||||
? value
|
||||
? 1
|
||||
: 0
|
||||
: value !== null && typeof value === "object"
|
||||
? JSON.stringify(value)
|
||||
: value,
|
||||
);
|
||||
return Number(sqlite.prepare(sql).run(...values).lastInsertRowid);
|
||||
};
|
||||
return { sqlite, insert };
|
||||
}
|
||||
|
||||
export interface BootedCore {
|
||||
dataDir: string;
|
||||
/** The live database core and the plugins write to; null on Postgres and MySQL. */
|
||||
sqlite: Database.Database | null;
|
||||
/** The 2.8 database file exactly as it was written to DATA_DIR, if one was. */
|
||||
seed: Buffer | null;
|
||||
/** keepDataDir writes the database to disk and leaves DATA_DIR for another boot. */
|
||||
shutdown: (options?: { keepDataDir?: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
/** A 2.8 data directory to boot: a seeded database, or a folder holding one. */
|
||||
export type BootSource =
|
||||
Database.Database | { fixtureDir: string } | { reuseDataDir: string };
|
||||
|
||||
/**
|
||||
* Lays a committed 2.8 install (fixtures/upgrade) into DATA_DIR: its key file
|
||||
* and the files its rows point at, with the fixture's DATA_DIR token in those
|
||||
* rows swapped for the real path, as 2.8 stored them absolute.
|
||||
*/
|
||||
function placeFixture(fixtureDir: string, dataDir: string): Buffer {
|
||||
fs.copyFileSync(
|
||||
path.join(fixtureDir, "fixture.env"),
|
||||
path.join(dataDir, ".env"),
|
||||
);
|
||||
fs.cpSync(path.join(fixtureDir, "files"), dataDir, { recursive: true });
|
||||
|
||||
const sqlite = new Database(
|
||||
fs.readFileSync(path.join(fixtureDir, "db.sqlite")),
|
||||
);
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
"UPDATE session_recordings SET recording_path = replace(recording_path, ?, ?)",
|
||||
)
|
||||
.run(DATA_DIR_TOKEN, dataDir.split(path.sep).join("/"));
|
||||
return sqlite.serialize();
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
const SYSTEM_SECRETS = [
|
||||
"JWT_SECRET",
|
||||
"DATABASE_KEY",
|
||||
"ENCRYPTION_KEY",
|
||||
"INTERNAL_AUTH_TOKEN",
|
||||
];
|
||||
|
||||
/**
|
||||
* Writes the seeded database into a fresh DATA_DIR and runs starter.ts's boot
|
||||
* sequence up to, not including, opening the HTTP server: the pre-upgrade
|
||||
* backup, the database, core's migrations, then the plugins and their data
|
||||
* moves. Call vi.resetModules() before, so module state from other tests is
|
||||
* gone.
|
||||
*/
|
||||
export async function bootCore(source: BootSource): Promise<BootedCore> {
|
||||
const dataDir =
|
||||
"reuseDataDir" in source
|
||||
? source.reuseDataDir
|
||||
: fs.mkdtempSync(path.join(os.tmpdir(), "termix-boot-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DB_FILE_ENCRYPTION = "false";
|
||||
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = BUILT_PLUGINS_DIR;
|
||||
// SystemCrypto prefers these over DATA_DIR/.env, and a previous boot in
|
||||
// this worker set them.
|
||||
for (const key of SYSTEM_SECRETS) delete process.env[key];
|
||||
|
||||
let seed: Buffer | null = null;
|
||||
if (source instanceof Database) {
|
||||
seed = source.serialize();
|
||||
} else if ("fixtureDir" in source) {
|
||||
seed = placeFixture(source.fixtureDir, dataDir);
|
||||
}
|
||||
|
||||
// TEST_DIALECT=postgres|mysql puts the same 2.8 data on a real server.
|
||||
const remote = remoteTestDialect();
|
||||
if (remote) {
|
||||
const url = process.env.TEST_DATABASE_URL;
|
||||
if (!url) throw new Error(`TEST_DIALECT=${remote} needs TEST_DATABASE_URL`);
|
||||
process.env.DATABASE_DIALECT = remote;
|
||||
process.env.DATABASE_URL = url;
|
||||
if (seed) await seedRemote28(remote, url, seed);
|
||||
} else if (seed) {
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed);
|
||||
}
|
||||
|
||||
const { SystemCrypto } = await import("../../utils/system-crypto.js");
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
await systemCrypto.initializeJWTSecret();
|
||||
await systemCrypto.initializeDatabaseKey();
|
||||
await systemCrypto.initializeEncryptionKey();
|
||||
await systemCrypto.initializeInternalAuthToken();
|
||||
|
||||
const { backupBeforeUpgrade, runCoreBootMigrations } =
|
||||
await import("../../boot.js");
|
||||
await backupBeforeUpgrade({ dataDir, version: "2.9.0-test" });
|
||||
|
||||
const db = await import("../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
|
||||
await runCoreBootMigrations();
|
||||
|
||||
const { primeKnownPermissions } =
|
||||
await import("../../utils/known-permissions.js");
|
||||
await primeKnownPermissions();
|
||||
|
||||
const { initializePlugins, shutdownPlugins } =
|
||||
await import("../../plugins/index.js");
|
||||
await initializePlugins();
|
||||
|
||||
const { runPluginDataMigrations } =
|
||||
await import("../../upgrade/boot-migrations.js");
|
||||
await runPluginDataMigrations();
|
||||
|
||||
return {
|
||||
dataDir,
|
||||
sqlite: remote ? null : db.getSqlite(),
|
||||
seed,
|
||||
shutdown: async ({ keepDataDir = false } = {}) => {
|
||||
await shutdownPlugins();
|
||||
if (keepDataDir) {
|
||||
if (!remote) {
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, "db.sqlite"),
|
||||
db.getSqlite().serialize(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* The built plugins really load: every bundle in dist/plugins activates on a
|
||||
* database that started in 2.8 shape, in dependency order, with every
|
||||
* service it needs provided and nothing registered twice.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
assertPluginsBuilt,
|
||||
bootCore,
|
||||
builtManifest,
|
||||
create28Database,
|
||||
sourcePluginIds,
|
||||
type BootedCore,
|
||||
} from "./built-harness.js";
|
||||
|
||||
const BOOT_TIMEOUT = 180_000;
|
||||
|
||||
let core: BootedCore;
|
||||
const logged = { errors: [] as string[], warnings: [] as string[] };
|
||||
|
||||
beforeAll(async () => {
|
||||
assertPluginsBuilt();
|
||||
vi.resetModules();
|
||||
const { sqlite } = create28Database();
|
||||
const error = vi.spyOn(console, "error").mockImplementation((...args) => {
|
||||
logged.errors.push(args.map(String).join(" "));
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation((...args) => {
|
||||
logged.warnings.push(args.map(String).join(" "));
|
||||
});
|
||||
try {
|
||||
core = await bootCore(sqlite);
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
warn.mockRestore();
|
||||
}
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await core?.shutdown();
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
describe("the built plugins on a 2.8 database", () => {
|
||||
it("boots without logging an error", () => {
|
||||
expect(logged.errors).toEqual([]);
|
||||
if (process.env.SHOW_BOOT_WARNINGS) {
|
||||
console.log(logged.warnings.join("\n"));
|
||||
}
|
||||
});
|
||||
|
||||
it("activates every plugin, none failed or blocked", async () => {
|
||||
const { getPluginRuntime } = await import("../../plugins/index.js");
|
||||
const states = Object.fromEntries(
|
||||
getPluginRuntime()
|
||||
.loader.list()
|
||||
.map((plugin) => [plugin.id, plugin.state]),
|
||||
);
|
||||
expect(Object.keys(states).sort()).toEqual(sourcePluginIds());
|
||||
const notActive = Object.entries(states).filter(
|
||||
([, state]) => state !== "active",
|
||||
);
|
||||
expect(notActive).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives every declared service a provider and meets every hard dependency", async () => {
|
||||
const { listServices } = await import("../../plugins/service-registry.js");
|
||||
const provided = new Set(
|
||||
listServices().map((service) => `${service.pluginId}:${service.service}`),
|
||||
);
|
||||
const ids = new Set(sourcePluginIds());
|
||||
|
||||
for (const id of ids) {
|
||||
const manifest = builtManifest(id) as {
|
||||
provides?: Array<{ service: string }>;
|
||||
requires?: Array<{ service: string; optional?: boolean }>;
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
for (const { service } of manifest.provides ?? []) {
|
||||
expect(provided, `${id} provides ${service}`).toContain(
|
||||
`${id}:${service}`,
|
||||
);
|
||||
}
|
||||
const available = new Set(
|
||||
listServices().map((service) => service.service),
|
||||
);
|
||||
for (const requirement of manifest.requires ?? []) {
|
||||
if (requirement.optional) continue;
|
||||
expect(available, `${id} requires ${requirement.service}`).toContain(
|
||||
requirement.service,
|
||||
);
|
||||
}
|
||||
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
|
||||
expect(ids, `${id} depends on ${dependency}`).toContain(dependency);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("registers no route, socket or permission that clashes", async () => {
|
||||
const { listRegistrationConflicts } =
|
||||
await import("../../plugins/conflicts.js");
|
||||
expect(listRegistrationConflicts()).toEqual([]);
|
||||
|
||||
const { getRegisteredWsRoutes } = await import("../../plugins/ws.js");
|
||||
const sockets = getRegisteredWsRoutes();
|
||||
expect(new Set(sockets).size).toBe(sockets.length);
|
||||
|
||||
// Plugin routes live under /plugin-api/<id>/, so a clash is two plugins
|
||||
// claiming one id, which the loader refuses; a legacy path must start
|
||||
// with its own plugin id.
|
||||
for (const id of sourcePluginIds()) {
|
||||
const legacy =
|
||||
(
|
||||
builtManifest(id) as {
|
||||
contributes?: { http?: { legacyPaths?: string[] } };
|
||||
}
|
||||
).contributes?.http?.legacyPaths ?? [];
|
||||
for (const legacyPath of legacy) {
|
||||
expect(legacyPath.startsWith(`/${id}/`)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
const { getPermissionCatalog } =
|
||||
await import("../../utils/permission-catalog.js");
|
||||
const permissions = getPermissionCatalog().flatMap(
|
||||
(entry) => entry.permissions,
|
||||
);
|
||||
expect(new Set(permissions).size).toBe(permissions.length);
|
||||
});
|
||||
|
||||
it("records every plugin migration it ran", async () => {
|
||||
const recorded = core
|
||||
.sqlite!.prepare(
|
||||
"SELECT plugin_id, COUNT(*) AS n FROM plugin_migrations GROUP BY plugin_id",
|
||||
)
|
||||
.all() as Array<{ plugin_id: string; n: number }>;
|
||||
const byPlugin = new Map(recorded.map((row) => [row.plugin_id, row.n]));
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const { BUILT_PLUGINS_DIR } = await import("./built-harness.js");
|
||||
for (const id of sourcePluginIds()) {
|
||||
const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite");
|
||||
const files = fs.existsSync(dir)
|
||||
? fs.readdirSync(dir).filter((name) => name.endsWith(".sql"))
|
||||
: [];
|
||||
expect(byPlugin.get(id) ?? 0, `${id} migrations`).toBe(files.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* The bundled plugins, as they ship.
|
||||
*
|
||||
* This reads plugins/ in the repo rather than a fixture: the manifests are
|
||||
* checked-in artifacts, and a typo in one would otherwise only show up when
|
||||
* the server tried to boot.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getBundledPluginsDir } from "../../plugins/paths.js";
|
||||
import { parseManifest } from "../../plugins/manifest.js";
|
||||
import { isKnownCapability } from "@termix/plugin-sdk/capabilities";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const BUNDLED_DIR = getBundledPluginsDir();
|
||||
|
||||
function bundledIds(): string[] {
|
||||
return fs
|
||||
.readdirSync(BUNDLED_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.filter((id) => fs.existsSync(path.join(BUNDLED_DIR, id, "manifest.json")));
|
||||
}
|
||||
|
||||
function readManifest(pluginId: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(BUNDLED_DIR, pluginId, "manifest.json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
const ids = bundledIds();
|
||||
|
||||
describe("bundled plugins", () => {
|
||||
it("finds the bundled plugin directory", () => {
|
||||
expect(ids.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(ids)("%s has a manifest that parses", (id) => {
|
||||
const { manifest, errors } = parseManifest(readManifest(id));
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.id).toBe(id);
|
||||
});
|
||||
|
||||
it.each(ids)("%s declares only catalog capabilities", (id) => {
|
||||
const { manifest } = parseManifest(readManifest(id));
|
||||
|
||||
for (const capability of manifest?.capabilities ?? []) {
|
||||
expect(isKnownCapability(capability)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(ids)("%s ships the entry points its manifest names", (id) => {
|
||||
const { manifest } = parseManifest(readManifest(id));
|
||||
const dir = path.join(BUNDLED_DIR, id);
|
||||
|
||||
expect(fs.existsSync(path.join(dir, manifest!.backend!))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, manifest!.frontend!))).toBe(true);
|
||||
});
|
||||
|
||||
// process:transport-owner was the v1 marker for the in-process tier. There
|
||||
// is one tier now, so nothing should still be asking for it.
|
||||
it.each(ids)("%s does not declare a v1 permission", (id) => {
|
||||
const raw = readManifest(id);
|
||||
|
||||
expect(raw.permissions).toBeUndefined();
|
||||
expect(raw.sidecars).toBeUndefined();
|
||||
expect(JSON.stringify(raw)).not.toContain("transport-owner");
|
||||
});
|
||||
});
|
||||
|
||||
describe("declared dependencies", () => {
|
||||
it("names a plugin that is actually bundled", () => {
|
||||
for (const id of ids) {
|
||||
const { manifest } = parseManifest(readManifest(id));
|
||||
for (const dependency of Object.keys(manifest?.dependencies ?? {})) {
|
||||
expect(ids).toContain(dependency);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ai reaches automations through the automations.access service, so it
|
||||
// keeps working when automations is off.
|
||||
it("keeps ai's use of automations optional", () => {
|
||||
if (!ids.includes("ai")) return;
|
||||
|
||||
const { manifest } = parseManifest(readManifest("ai"));
|
||||
|
||||
expect(manifest?.dependencies?.automations).toBeUndefined();
|
||||
expect(manifest?.optionalDependencies?.automations).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugins that own a port", () => {
|
||||
// Every one of these had its server created at module scope at some point.
|
||||
// The rule is that it belongs inside activate, or disable leaves the port
|
||||
// held and re-enable fails.
|
||||
it.each(["ssh-terminal", "docker", "host-metrics", "remote-desktop"])(
|
||||
"%s creates no listener at module scope",
|
||||
(id) => {
|
||||
if (!ids.includes(id)) return;
|
||||
|
||||
const entry = path.join(BUNDLED_DIR, id, "src", "backend", "index.ts");
|
||||
const source = fs.readFileSync(entry, "utf8");
|
||||
const moduleScope = source.slice(
|
||||
0,
|
||||
source.indexOf("export async function activate"),
|
||||
);
|
||||
|
||||
expect(moduleScope).not.toMatch(/\.listen\(/);
|
||||
expect(moduleScope).not.toMatch(/new WebSocketServer/);
|
||||
},
|
||||
);
|
||||
|
||||
it("docker does not register a process signal handler", () => {
|
||||
if (!ids.includes("docker")) return;
|
||||
|
||||
// Core owns shutdown: gracefulShutdown calls shutdownPlugins, which runs
|
||||
// every plugin's deactivate. A plugin calling process.exit() itself
|
||||
// skipped the rest of that sequence.
|
||||
for (const file of ["index.ts", "console.ts"]) {
|
||||
const candidate = path.join(
|
||||
BUNDLED_DIR,
|
||||
"docker",
|
||||
"src",
|
||||
"backend",
|
||||
file,
|
||||
);
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
|
||||
// Comments are stripped first: the rule is worth explaining in prose
|
||||
// right where it applies, and that prose names the thing it forbids.
|
||||
const code = fs
|
||||
.readFileSync(candidate, "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
|
||||
expect(code).not.toMatch(/process\.on\(\s*["']SIG/);
|
||||
expect(code).not.toMatch(/process\.exit\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* The capability gate.
|
||||
*
|
||||
* Two conditions, both required: the manifest declares it and it is granted.
|
||||
* Either missing is a refusal, and both the refusal and the allowed call are
|
||||
* audited with the plugin id and the acting user, because "who did this"
|
||||
* being answerable is the point of the gate.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
const kvStore = new Map<string, string>();
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentPluginStorageRepository: () => ({
|
||||
get: async (pluginId: string, key: string) =>
|
||||
kvStore.get(`${pluginId}:${key}`) ?? null,
|
||||
set: async (pluginId: string, key: string, value: string) => {
|
||||
kvStore.set(`${pluginId}:${key}`, value);
|
||||
},
|
||||
delete: async (pluginId: string, key: string) =>
|
||||
kvStore.delete(`${pluginId}:${key}`),
|
||||
listKeys: async () => [...kvStore.keys()],
|
||||
countKeys: async (pluginId: string) =>
|
||||
[...kvStore.keys()].filter((key) => key.startsWith(`${pluginId}:`))
|
||||
.length,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
const { createPluginContext, createPluginHandle } =
|
||||
await import("../../plugins/ctx.js");
|
||||
const { invalidatePluginPermissionCache } =
|
||||
await import("../../plugins/permissions.js");
|
||||
const { runAsActor } = await import("../../plugins/actor.js");
|
||||
|
||||
function manifest(capabilities: string[]) {
|
||||
return {
|
||||
id: "sample-plugin",
|
||||
name: "Sample Plugin",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "Termix Tests" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as never;
|
||||
}
|
||||
|
||||
function context(declared: string[]) {
|
||||
const value = manifest(declared);
|
||||
const handle = createPluginHandle("sample-plugin", {
|
||||
activate: () => {},
|
||||
});
|
||||
return createPluginContext(value, handle);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
kvStore.clear();
|
||||
invalidatePluginPermissionCache();
|
||||
});
|
||||
|
||||
describe("capability gate", () => {
|
||||
it("denies a capability the manifest never declared", async () => {
|
||||
// Granted in the database, but absent from the manifest. Widening a
|
||||
// plugin's reach must always require a new manifest the user can see.
|
||||
grants.set("sample-plugin", ["kv:own"]);
|
||||
const ctx = context([]);
|
||||
|
||||
await expect(ctx.kv.get("anything")).rejects.toThrow(
|
||||
/not granted the "kv:own" capability/,
|
||||
);
|
||||
});
|
||||
|
||||
it("denies a declared capability that has not been granted", async () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
await expect(ctx.kv.get("anything")).rejects.toThrow(
|
||||
/not granted the "kv:own" capability/,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a capability that is both declared and granted", async () => {
|
||||
grants.set("sample-plugin", ["kv:own"]);
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
await ctx.kv.set("colour", "blue");
|
||||
|
||||
await expect(ctx.kv.get("colour")).resolves.toBe("blue");
|
||||
});
|
||||
|
||||
it("audits an allowed call with the plugin id and the actor", async () => {
|
||||
grants.set("sample-plugin", ["kv:own"]);
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
await runAsActor("user-42", "request", () => ctx.kv.set("k", 1));
|
||||
|
||||
const entry = auditEntries.find((e) => e.action === "plugin_kv_set");
|
||||
expect(entry).toMatchObject({
|
||||
userId: "user-42",
|
||||
username: "plugin:sample-plugin",
|
||||
resourceType: "plugin",
|
||||
resourceId: "sample-plugin",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
// A refused call is exactly the thing an operator wants to find later.
|
||||
it("audits a denied call as a failure", async () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
await expect(
|
||||
runAsActor("user-42", "request", () => ctx.kv.get("k")),
|
||||
).rejects.toThrow();
|
||||
|
||||
const entry = auditEntries.find((e) => e.action === "plugin_kv_get");
|
||||
expect(entry).toMatchObject({ userId: "user-42", success: false });
|
||||
expect(String(entry?.errorMessage)).toMatch(/not granted/);
|
||||
});
|
||||
|
||||
it("picks up a grant made after the first denial", async () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
await expect(ctx.kv.get("k")).rejects.toThrow();
|
||||
|
||||
grants.set("sample-plugin", ["kv:own"]);
|
||||
invalidatePluginPermissionCache("sample-plugin");
|
||||
|
||||
await expect(ctx.kv.get("k")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.asUser", () => {
|
||||
it("makes the named user the actor and audits the switch", async () => {
|
||||
grants.set("sample-plugin", ["kv:own"]);
|
||||
const ctx = context(["kv:own", "users:impersonate"]);
|
||||
|
||||
let seen: string | undefined;
|
||||
await ctx.asUser("user-7", async () => {
|
||||
seen = ctx.currentActor();
|
||||
await ctx.kv.set("k", 1);
|
||||
});
|
||||
|
||||
expect(seen).toBe("user-7");
|
||||
|
||||
expect(
|
||||
auditEntries.find((e) => e.action === "plugin_as_user"),
|
||||
).toMatchObject({ resourceId: "sample-plugin", success: true });
|
||||
|
||||
expect(
|
||||
auditEntries.find((e) => e.action === "plugin_kv_set"),
|
||||
).toMatchObject({ userId: "user-7" });
|
||||
});
|
||||
|
||||
it("restores the previous actor afterwards", async () => {
|
||||
const ctx = context(["users:impersonate"]);
|
||||
|
||||
await runAsActor("outer-user", "request", async () => {
|
||||
await ctx.asUser("inner-user", async () => {
|
||||
expect(ctx.currentActor()).toBe("inner-user");
|
||||
});
|
||||
expect(ctx.currentActor()).toBe("outer-user");
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an empty user id", async () => {
|
||||
const ctx = context([]);
|
||||
|
||||
await expect(ctx.asUser("", async () => {})).rejects.toThrow(
|
||||
/without a user id/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses without users:impersonate", async () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
await expect(ctx.asUser("user-7", async () => {})).rejects.toThrow(
|
||||
/users:impersonate/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.registry namespacing", () => {
|
||||
it("lets a plugin provide and revoke its own keys", () => {
|
||||
const ctx = context([]);
|
||||
const value = { ok: true };
|
||||
|
||||
ctx.registry.provide("sample-plugin.thing", value);
|
||||
expect(ctx.registry.consume("sample-plugin.thing")).toBe(value);
|
||||
expect(ctx.registry.revoke("sample-plugin.thing", value)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a key outside the plugin's namespace", () => {
|
||||
const ctx = context([]);
|
||||
|
||||
expect(() => ctx.registry.provide("other.thing", {})).toThrow(
|
||||
/registry keys under "sample-plugin\."/,
|
||||
);
|
||||
expect(() => ctx.registry.revoke("other.thing")).toThrow(
|
||||
/registry keys under/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.services.get", () => {
|
||||
it("refuses a service the manifest does not require", () => {
|
||||
const ctx = context([]);
|
||||
|
||||
expect(() => ctx.services.get("someone.else")).toThrow(
|
||||
/not declared in the manifest's requires/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.events namespacing", () => {
|
||||
it("refuses a core topic without events:core", () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
// Without this a plugin could publish host.status and drive the
|
||||
// automations engine as though core had.
|
||||
expect(() => ctx.events.emit("host.status", {})).toThrow(
|
||||
/may only emit topics under/,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows the plugin's own namespace", () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
expect(() =>
|
||||
ctx.events.emit("plugin.sample-plugin.thing", {}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows a core topic when events:core is declared", () => {
|
||||
const ctx = context(["events:core"]);
|
||||
|
||||
expect(() => ctx.events.emit("host.status", {})).not.toThrow();
|
||||
});
|
||||
|
||||
it("gates listening to core topics the same way", () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
expect(() => ctx.events.on("user.deleted", () => {})).toThrow(
|
||||
/may only listen to/,
|
||||
);
|
||||
expect(() =>
|
||||
ctx.events.on("plugin.other-plugin.thing", () => {}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
context(["events:core"]).events.on("user.deleted", () => {}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.kv limits", () => {
|
||||
beforeEach(() => {
|
||||
grants.set("sample-plugin", ["kv:own"]);
|
||||
});
|
||||
|
||||
it("rejects an over-long key", async () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
await expect(ctx.kv.set("k".repeat(129), 1)).rejects.toThrow(
|
||||
/at most 128 characters/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a value over the size cap", async () => {
|
||||
const ctx = context(["kv:own"]);
|
||||
|
||||
await expect(ctx.kv.set("big", "x".repeat(300_000))).rejects.toThrow(
|
||||
/byte limit/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ctx.audit.record: a plugin writes an audit line under its own action name,
|
||||
* and the user it names is always the runtime's actor, never one the plugin
|
||||
* picked.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(pluginId: string) {
|
||||
const manifest = {
|
||||
id: pluginId,
|
||||
name: "Audit Fixture",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
auditEntries.length = 0;
|
||||
});
|
||||
|
||||
describe("ctx.audit.record", () => {
|
||||
it("writes the plugin's action for the acting user", async () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await runAsActor("user-3", "request", () =>
|
||||
ctx.audit.record({
|
||||
action: "ssh_connect",
|
||||
resourceType: "host",
|
||||
resourceId: "12",
|
||||
resourceName: "root@10.0.0.1:22",
|
||||
success: true,
|
||||
}),
|
||||
);
|
||||
expect(auditEntries).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: "user-3",
|
||||
action: "ssh_connect",
|
||||
resourceType: "host",
|
||||
resourceId: "12",
|
||||
resourceName: "root@10.0.0.1:22",
|
||||
details: "via plugin audit-fixture",
|
||||
success: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the plugin as the resource and system as the user", async () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await ctx.audit.record({ action: "cleanup", success: false });
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
userId: "system",
|
||||
resourceType: "plugin",
|
||||
resourceId: "audit-fixture",
|
||||
resourceName: "Audit Fixture",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.audit.record with a request", () => {
|
||||
it("records the request's IP address and user agent", async () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await ctx.audit.record({
|
||||
action: "collab_room_end",
|
||||
success: true,
|
||||
request: {
|
||||
ip: "203.0.113.4",
|
||||
headers: { "user-agent": "TestAgent/1.0" },
|
||||
},
|
||||
});
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
ipAddress: "203.0.113.4",
|
||||
userAgent: "TestAgent/1.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the socket address on an upgrade request", async () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await ctx.audit.record({
|
||||
action: "collab_join",
|
||||
success: true,
|
||||
request: { headers: {}, socket: { remoteAddress: "198.51.100.9" } },
|
||||
});
|
||||
expect(auditEntries[0]).toMatchObject({ ipAddress: "198.51.100.9" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* A plugin's login method through core: what its identity may carry, how its
|
||||
* own callback route hands off to core, and that a disabled plugin leaves no
|
||||
* way in, including through the 2.8 wrapper routes.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
createAuthState,
|
||||
fakeAuthManager,
|
||||
fakeRequest,
|
||||
fakeResponse,
|
||||
type AuthState,
|
||||
} from "../auth/auth-test-helpers.js";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
state: null as unknown as AuthState,
|
||||
manager: null as unknown as ReturnType<
|
||||
typeof import("../auth/auth-test-helpers.js").fakeAuthManager
|
||||
>,
|
||||
granted: new Set<string>(),
|
||||
trustedProxy: false,
|
||||
audits: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", async () => {
|
||||
const helpers = await import("../auth/auth-test-helpers.js");
|
||||
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
|
||||
return Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
(...args: unknown[]) =>
|
||||
(
|
||||
helpers.fakeFactory(h.state) as Record<
|
||||
string,
|
||||
(...a: unknown[]) => unknown
|
||||
>
|
||||
)[name](...args),
|
||||
]),
|
||||
);
|
||||
});
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => h.manager },
|
||||
}));
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
h.state.audits.push(entry);
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../utils/trusted-proxy-auth.js", () => ({
|
||||
isTrustedProxyAuthEnabled: () => h.trustedProxy,
|
||||
}));
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../hosts/internal-events.js", () => ({
|
||||
emitInternalEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
return {
|
||||
authLogger: log,
|
||||
databaseLogger: log,
|
||||
sshLogger: log,
|
||||
pluginLogger: log,
|
||||
logger: log,
|
||||
};
|
||||
});
|
||||
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
|
||||
}));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
|
||||
SharedCredentialSecretsManager: {
|
||||
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
const { createPluginAuth } = await import("../../plugins/ctx-ssh-auth.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
|
||||
const {
|
||||
listPublicLoginMethods,
|
||||
listLegacySsoProviders,
|
||||
startRedirectLogin,
|
||||
verifyFormLogin,
|
||||
} = await import("../../database/routes/auth-routes.js");
|
||||
const { runLogin } = await import("../../auth/login-pipeline.js");
|
||||
const { resetAuthRegistryForTests, getLoginMethod } =
|
||||
await import("../../auth/registry.js");
|
||||
const { resetCoreLoginProvidersForTests } =
|
||||
await import("../../auth/core-auth.js");
|
||||
|
||||
const manifest = {
|
||||
id: "corp",
|
||||
capabilities: ["auth:provide"],
|
||||
contributes: { auth: { loginMethods: ["corp-sso", "corp-dir"] } },
|
||||
} as unknown as PluginManifest;
|
||||
|
||||
function setup() {
|
||||
const bag = new DisposableBag();
|
||||
const auth = createPluginAuth({
|
||||
manifest,
|
||||
bag,
|
||||
audit: async (action) => {
|
||||
h.audits.push(action);
|
||||
},
|
||||
});
|
||||
auth.registerLoginMethod({
|
||||
id: "corp-sso",
|
||||
labelKey: "signIn",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
describe: async () => [
|
||||
{ id: "3", label: "Corp", enabled: true, type: "oidc", autoStart: true },
|
||||
],
|
||||
start: async () => ({ redirectUrl: "https://idp.example/auth" }),
|
||||
callback: async () => ({
|
||||
kind: "external",
|
||||
provider: "3",
|
||||
subject: "sub-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
isAdmin: true,
|
||||
roles: { desired: ["ops"], managed: ["ops", "dev"] },
|
||||
logoutClaims: { providerId: 3, sub: "sub-1", sid: "sid-1" },
|
||||
legacy: { identifier: "sub-1", providerRowId: 3 },
|
||||
returnTo: "https://termix.example",
|
||||
}),
|
||||
});
|
||||
auth.registerLoginMethod({
|
||||
id: "corp-dir",
|
||||
labelKey: "signIn",
|
||||
kind: "form",
|
||||
external: true,
|
||||
describe: async () => [
|
||||
{ id: "4", label: "Directory", enabled: true, autoStart: true },
|
||||
],
|
||||
verify: async (request) => {
|
||||
if (request.body.password !== "hunter2") {
|
||||
await auth.loginRateLimit.recordFailure(
|
||||
request.ip ?? "unknown",
|
||||
"4:bob",
|
||||
);
|
||||
throw new (await import("@termix/plugin-sdk/backend")).LoginMethodError(
|
||||
"Invalid username or password",
|
||||
401,
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: "external",
|
||||
provider: "ldap:4",
|
||||
subject: "bob",
|
||||
name: "Bob",
|
||||
legacy: { identifier: "ldap:4:bob", providerRowId: 4 },
|
||||
rateLimitKey: "4:bob",
|
||||
};
|
||||
},
|
||||
});
|
||||
return { auth, bag };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.state = createAuthState();
|
||||
h.manager = fakeAuthManager(h.state);
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
h.trustedProxy = false;
|
||||
h.audits = [];
|
||||
resetAuthRegistryForTests();
|
||||
resetCoreLoginProvidersForTests();
|
||||
});
|
||||
|
||||
describe("a plugin's external identity", () => {
|
||||
it("carries the 2.8 identifier, role map and logout claims into core", async () => {
|
||||
setup();
|
||||
const identity = await getLoginMethod("corp-sso")!.callback!(
|
||||
fakeRequest() as never,
|
||||
);
|
||||
const result = await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "corp-sso",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("session");
|
||||
|
||||
const [user] = [...h.state.users.values()];
|
||||
expect(user).toMatchObject({ oidcIdentifier: "sub-1", ssoProviderId: 3 });
|
||||
expect(h.state.identities).toContainEqual(
|
||||
expect.objectContaining({ providerId: "3", subject: "sub-1" }),
|
||||
);
|
||||
expect(h.state.roles).toContainEqual({ userId: user.id, roleName: "ops" });
|
||||
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
expect.objectContaining({
|
||||
ssoProviderId: 3,
|
||||
oidcSub: "sub-1",
|
||||
oidcSid: "sid-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot set core-only fields", async () => {
|
||||
const { toCoreIdentity } = await import("../../plugins/ctx-ssh-auth.js");
|
||||
const mapped = toCoreIdentity("corp", {
|
||||
kind: "external",
|
||||
provider: "3",
|
||||
subject: "x",
|
||||
unlockError: "spoofed",
|
||||
rateLimitUsername: "someone-else",
|
||||
} as never);
|
||||
expect(mapped).not.toHaveProperty("unlockError", "spoofed");
|
||||
expect(mapped.rateLimitUsername).toBeUndefined();
|
||||
});
|
||||
|
||||
it("finds a 2.8 user by the old identifier and links the identity", async () => {
|
||||
h.state.users.set("u-1", {
|
||||
id: "u-1",
|
||||
username: "Alice",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "sub-1",
|
||||
});
|
||||
setup();
|
||||
const identity = await getLoginMethod("corp-sso")!.callback!(
|
||||
fakeRequest() as never,
|
||||
);
|
||||
await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "corp-sso",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(h.state.users.size).toBe(1);
|
||||
expect(h.state.identities).toContainEqual(
|
||||
expect.objectContaining({ userId: "u-1", providerId: "3" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.auth.completeRedirectLogin", () => {
|
||||
it("runs the method's callback and redirects back with a session", async () => {
|
||||
const { auth } = setup();
|
||||
const res = fakeResponse();
|
||||
await auth.completeRedirectLogin(
|
||||
"corp-sso",
|
||||
fakeRequest({ query: { code: "c", state: "s" } }),
|
||||
res,
|
||||
);
|
||||
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
|
||||
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
|
||||
});
|
||||
|
||||
it("refuses a method the plugin did not declare", async () => {
|
||||
const { auth } = setup();
|
||||
await expect(
|
||||
auth.completeRedirectLogin("oidc", fakeRequest(), fakeResponse()),
|
||||
).rejects.toThrow(/not listed/);
|
||||
});
|
||||
|
||||
it("refuses without auth:provide", async () => {
|
||||
const { auth } = setup();
|
||||
h.granted.clear();
|
||||
await expect(
|
||||
auth.completeRedirectLogin("corp-sso", fakeRequest(), fakeResponse()),
|
||||
).rejects.toMatchObject({ name: "PluginCapabilityError" });
|
||||
});
|
||||
|
||||
it("is refused while trusted proxy login is on", async () => {
|
||||
const { auth } = setup();
|
||||
h.trustedProxy = true;
|
||||
const res = fakeResponse();
|
||||
await auth.completeRedirectLogin("corp-sso", fakeRequest(), res);
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.cookies).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the other ctx.auth helpers", () => {
|
||||
it("revokes sessions by logout claims and audits it", async () => {
|
||||
const { auth } = setup();
|
||||
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
|
||||
expect(h.manager.revokeSessionsByExternalSession).toHaveBeenCalledWith({
|
||||
ssoProviderId: 3,
|
||||
sub: null,
|
||||
sid: "sid-1",
|
||||
});
|
||||
expect(h.audits).toContain("auth_sessions_revoked");
|
||||
expect(await auth.revokeSessions({ providerId: 3 })).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps rate limit keys apart per plugin and clears them on success", async () => {
|
||||
const { auth } = setup();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await auth.loginRateLimit.recordFailure("10.9.9.9", "4:bob");
|
||||
}
|
||||
expect(
|
||||
(await auth.loginRateLimit.isLocked("10.9.9.9", "4:bob")).locked,
|
||||
).toBe(true);
|
||||
// Core's own key for the same name is untouched.
|
||||
expect(loginRateLimiter.isLocked("10.9.9.8", "4:bob").locked).toBe(false);
|
||||
loginRateLimiter.resetAttempts("10.9.9.9", "plugin:corp:4:bob");
|
||||
});
|
||||
|
||||
it("counts linked users for a provider", async () => {
|
||||
const { auth } = setup();
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "u-1",
|
||||
providerId: "ldap:4",
|
||||
subject: "bob",
|
||||
email: null,
|
||||
});
|
||||
expect(await auth.countLinkedUsers("ldap:4")).toBe(1);
|
||||
expect(await auth.countLinkedUsers("3")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a disabled plugin", () => {
|
||||
it("lists its instances while active, for the login screen and 2.8 clients", async () => {
|
||||
setup();
|
||||
const methods = await listPublicLoginMethods();
|
||||
expect(methods.map((method) => method.id)).toEqual(
|
||||
expect.arrayContaining(["corp-sso", "corp-dir"]),
|
||||
);
|
||||
expect(await listLegacySsoProviders()).toEqual([
|
||||
{ id: 3, name: "Corp", type: "oidc", displayOrder: 0 },
|
||||
{ id: 4, name: "Directory", type: "corp-dir", displayOrder: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes autoStart through for redirect methods only", async () => {
|
||||
setup();
|
||||
const methods = await listPublicLoginMethods();
|
||||
expect(methods.find((m) => m.id === "corp-sso")?.instances).toEqual([
|
||||
{ id: "3", label: "Corp", autoStart: true },
|
||||
]);
|
||||
expect(methods.find((m) => m.id === "corp-dir")?.instances).toEqual([
|
||||
{ id: "4", label: "Directory" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides its buttons and leaves no route that signs anyone in", async () => {
|
||||
const { bag } = setup();
|
||||
await bag.disposeAll();
|
||||
|
||||
const methods = await listPublicLoginMethods();
|
||||
expect(methods.map((method) => method.id)).toEqual(["password"]);
|
||||
expect(await listLegacySsoProviders()).toEqual([]);
|
||||
|
||||
const start = fakeResponse();
|
||||
await startRedirectLogin(
|
||||
fakeRequest() as never,
|
||||
start as never,
|
||||
"corp-sso",
|
||||
"3",
|
||||
);
|
||||
expect(start.statusCode).toBe(404);
|
||||
|
||||
const verify = fakeResponse();
|
||||
await verifyFormLogin(
|
||||
fakeRequest({ body: { username: "bob", password: "hunter2" } }) as never,
|
||||
verify as never,
|
||||
"corp-dir",
|
||||
"4",
|
||||
);
|
||||
expect(verify.statusCode).toBe(404);
|
||||
expect(verify.cookies).toHaveLength(0);
|
||||
expect(h.state.users.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("form methods and the external second factor setting", () => {
|
||||
function enrolledBob() {
|
||||
h.state.users.set("u-bob", {
|
||||
id: "u-bob",
|
||||
username: "Bob",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
});
|
||||
h.state.factors.push({
|
||||
userId: "u-bob",
|
||||
pluginId: "fixture",
|
||||
factorId: "pin",
|
||||
});
|
||||
}
|
||||
|
||||
it("skips second factors when the setting is off", async () => {
|
||||
enrolledBob();
|
||||
setup();
|
||||
const res = fakeResponse();
|
||||
await verifyFormLogin(
|
||||
fakeRequest({ body: { password: "hunter2" } }) as never,
|
||||
res as never,
|
||||
"corp-dir",
|
||||
"4",
|
||||
);
|
||||
expect(res.body).toMatchObject({ success: true });
|
||||
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
|
||||
});
|
||||
|
||||
it("asks for them when the setting is on", async () => {
|
||||
enrolledBob();
|
||||
h.state.settings.set("second_factor_after_external_login", "true");
|
||||
setup();
|
||||
const { registerSecondFactor } = await import("../../auth/registry.js");
|
||||
registerSecondFactor({
|
||||
id: "pin",
|
||||
pluginId: "fixture",
|
||||
labelKey: "pin",
|
||||
isEnrolled: async () => false,
|
||||
verify: async () => true,
|
||||
});
|
||||
const res = fakeResponse();
|
||||
await verifyFormLogin(
|
||||
fakeRequest({ body: { password: "hunter2" } }) as never,
|
||||
res as never,
|
||||
"corp-dir",
|
||||
"4",
|
||||
);
|
||||
expect(res.body).toMatchObject({ requires_second_factor: true });
|
||||
});
|
||||
|
||||
it("refuses a wrong password without a session", async () => {
|
||||
setup();
|
||||
const res = fakeResponse();
|
||||
await verifyFormLogin(
|
||||
fakeRequest({ body: { password: "nope" } }) as never,
|
||||
res as never,
|
||||
"corp-dir",
|
||||
"4",
|
||||
);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.cookies).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* ctx.capabilities: a generic check for privileged code no ctx method wraps,
|
||||
* used first by the serial plugin to gate opening a physical device. Every
|
||||
* `require` call is audited, allowed or refused, the same as a guarded ctx
|
||||
* method.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
|
||||
return {
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
}
|
||||
|
||||
function contextFor(pluginId: string, capabilities: string[]) {
|
||||
const manifest = manifestFor(pluginId, capabilities);
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return { ctx: createPluginContext(manifest, handle), handle };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
});
|
||||
|
||||
describe("ctx.capabilities", () => {
|
||||
it("has() is false when the manifest never declared the capability", async () => {
|
||||
grants.set("demo", ["device:serial"]);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.capabilities.has("device:serial")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("has() is false when declared but not granted", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", ["device:serial"]);
|
||||
|
||||
await expect(ctx.capabilities.has("device:serial")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("has() is true when declared and granted", async () => {
|
||||
grants.set("demo", ["device:serial"]);
|
||||
const { ctx } = contextFor("demo", ["device:serial"]);
|
||||
|
||||
await expect(ctx.capabilities.has("device:serial")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("require() throws and audits a refusal without the capability", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.capabilities.require("device:serial")).rejects.toThrow(
|
||||
/device:serial/,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_capability_require",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("require() resolves and audits success when granted", async () => {
|
||||
grants.set("demo", ["device:serial"]);
|
||||
const { ctx } = contextFor("demo", ["device:serial"]);
|
||||
|
||||
await expect(
|
||||
ctx.capabilities.require("device:serial"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_capability_require",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* ctx.credentials.resolveHostProtocol: credentials:read is checked and every
|
||||
* call audited, the owner gets their stored or credential-mode login, and a
|
||||
* shared recipient only ever sees what core's sharing rules hand them.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
hosts: new Map<number, Record<string, unknown>>(),
|
||||
credentials: new Map<number, { username: string; password: string }>(),
|
||||
canConnect: true,
|
||||
resolution: null as Record<string, unknown> | null,
|
||||
resolverCalls: [] as Array<Record<string, unknown>>,
|
||||
savedCredentials: [] as Array<Record<string, unknown>>,
|
||||
userCredentials: new Map<string, Array<Record<string, unknown>>>(),
|
||||
corePermissions: new Set<string>(["credentials.view", "credentials.create"]),
|
||||
logins: new Map<string, Record<string, unknown>>(),
|
||||
ownerDecrypts: 0,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentCredentialRepository: () => ({
|
||||
listDecryptedByUserId: async (userId: string) =>
|
||||
state.userCredentials.get(userId) ?? [],
|
||||
createEncryptedForUser: async (
|
||||
userId: string,
|
||||
data: Record<string, unknown>,
|
||||
) => {
|
||||
const row = { ...data, id: 100 + state.savedCredentials.length, userId };
|
||||
state.savedCredentials.push(row);
|
||||
return row;
|
||||
},
|
||||
}),
|
||||
createCurrentHostProtocolAuthRepository: () => ({
|
||||
find: async (hostId: number, protocol: string) => {
|
||||
state.ownerDecrypts++;
|
||||
return state.logins.get(`${hostId}:${protocol}`) ?? null;
|
||||
},
|
||||
findRow: async (hostId: number, protocol: string) => {
|
||||
const login = state.logins.get(`${hostId}:${protocol}`);
|
||||
return login
|
||||
? {
|
||||
hostId,
|
||||
protocol,
|
||||
authType: login.authType,
|
||||
credentialId: login.credentialId ?? null,
|
||||
username: login.username ?? null,
|
||||
password: "sealed",
|
||||
fields: JSON.stringify(login.fields ?? {}),
|
||||
secretFields: "sealed",
|
||||
}
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||
listCredentialIds: async () => ({}),
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async (hostId: number) =>
|
||||
(state.hosts.get(hostId)?.userId as string) ?? null,
|
||||
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
||||
findCredentialByIdForUser: async (id: number) =>
|
||||
state.credentials.get(id) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
validateUserAccess: () => Buffer.alloc(32),
|
||||
getUserDataKey: () => Buffer.alloc(32),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async (id: number) => state.credentials.get(id) ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async () => ({ hasAccess: state.canConnect }),
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
state.corePermissions.has(permission),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/shared-host-auth-resolver.js", () => ({
|
||||
resolveRecipientSharedHostAuthentication: async (
|
||||
host: Record<string, unknown>,
|
||||
) => {
|
||||
state.resolverCalls.push(host);
|
||||
return state.resolution;
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import {
|
||||
getSecretResolver,
|
||||
requireSecretResolver,
|
||||
resetSecretResolverRegistryForTests,
|
||||
} from "../../hosts/connect/secret-resolver-registry.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import ssh2 from "ssh2";
|
||||
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
|
||||
|
||||
const SPICE = {
|
||||
id: "spice",
|
||||
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
|
||||
hostLoginFallback: ["password" as const],
|
||||
};
|
||||
|
||||
function contextFor(
|
||||
capabilities: string[],
|
||||
contributes?: PluginManifest["contributes"],
|
||||
) {
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
name: "demo",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
contributes,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle("demo", { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
const baseHost = {
|
||||
id: 7,
|
||||
userId: "owner",
|
||||
name: "Win box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "sshuser",
|
||||
password: "ssh-secret",
|
||||
jumpHosts: JSON.stringify([{ hostId: 3 }]),
|
||||
};
|
||||
|
||||
/** The demo plugin declares SPICE; nothing in core knows it. */
|
||||
function spiceContext(capabilities: string[]) {
|
||||
return contextFor(capabilities, { protocols: [SPICE] });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
state.hosts.clear();
|
||||
state.credentials.clear();
|
||||
state.canConnect = true;
|
||||
state.resolution = null;
|
||||
state.resolverCalls.length = 0;
|
||||
state.savedCredentials.length = 0;
|
||||
state.userCredentials.clear();
|
||||
state.corePermissions = new Set(["credentials.view", "credentials.create"]);
|
||||
state.hosts.set(7, { ...baseHost });
|
||||
state.logins.clear();
|
||||
state.ownerDecrypts = 0;
|
||||
state.logins.set("7:spice", {
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "admin",
|
||||
password: "spice-secret",
|
||||
fields: { display: "2" },
|
||||
secretFields: { ticket: "t-123" },
|
||||
});
|
||||
setHostProtocolSource(() => [
|
||||
{ ...SPICE, pluginId: "demo", pluginName: "demo" },
|
||||
{ id: "rival", pluginId: "other", pluginName: "other" },
|
||||
]);
|
||||
resetSecretResolverRegistryForTests();
|
||||
});
|
||||
|
||||
describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
it("refuses without credentials:read and audits the refusal", async () => {
|
||||
grants.set("demo", []);
|
||||
const ctx = spiceContext([]);
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
),
|
||||
).rejects.toThrow(/credentials:read/);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_read",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a protocol the plugin does not declare, or another plugin owns", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
const ctx = contextFor(["credentials:read"], {
|
||||
protocols: [SPICE, { id: "rival" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "vnc"),
|
||||
),
|
||||
).rejects.toThrow(/does not declare/);
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "rival"),
|
||||
),
|
||||
).rejects.toThrow(/does not declare/);
|
||||
expect(state.ownerDecrypts).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the owner's stored login and audits it", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
);
|
||||
expect(target).toEqual({
|
||||
host: {
|
||||
id: 7,
|
||||
name: "Win box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
ownerUserId: "owner",
|
||||
jumpHosts: [{ hostId: 3 }],
|
||||
},
|
||||
shared: false,
|
||||
auth: {
|
||||
authType: "direct",
|
||||
username: "admin",
|
||||
password: "spice-secret",
|
||||
fields: { display: "2", ticket: "t-123" },
|
||||
},
|
||||
});
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_read",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the host's own password as declared", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.logins.set("7:spice", {
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: {},
|
||||
secretFields: {},
|
||||
});
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
);
|
||||
expect(target?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "",
|
||||
password: "ssh-secret",
|
||||
fields: { display: "", ticket: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the stored credential in credential mode", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.logins.set("7:spice", {
|
||||
protocol: "spice",
|
||||
authType: "credential",
|
||||
credentialId: 11,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: { display: "1" },
|
||||
secretFields: {},
|
||||
});
|
||||
state.credentials.set(11, { username: "viewer", password: "cred-pass" });
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
);
|
||||
expect(target?.auth).toMatchObject({
|
||||
authType: "credential",
|
||||
username: "viewer",
|
||||
password: "cred-pass",
|
||||
fields: { display: "1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("never hands a shared recipient the owner's raw secrets", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.resolution = {
|
||||
source: "owner-shared",
|
||||
authType: "direct",
|
||||
secret: {
|
||||
username: "shared",
|
||||
password: "snap",
|
||||
fields: { ticket: "snap-ticket" },
|
||||
},
|
||||
};
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
);
|
||||
expect(target?.shared).toBe(true);
|
||||
expect(target?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "shared",
|
||||
password: "snap",
|
||||
fields: { display: "2", ticket: "snap-ticket" },
|
||||
});
|
||||
expect(state.resolverCalls[0]).toMatchObject({ password: null });
|
||||
// The owner's login is never decrypted for a recipient.
|
||||
expect(state.ownerDecrypts).toBe(0);
|
||||
});
|
||||
|
||||
it("gives a recipient with nothing shared an empty login", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.resolution = { source: "required" };
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
);
|
||||
expect(target?.auth.username).toBe("");
|
||||
expect(target?.auth.password).toBe("");
|
||||
expect(target?.auth.fields.ticket).toBe("");
|
||||
expect(state.ownerDecrypts).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null without connect access or for a missing host", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.canConnect = false;
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
await expect(
|
||||
runAsActor("guest", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(99, "spice"),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(auditEntries.at(-1)).toMatchObject({ success: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.credentials.registerSecretResolver", () => {
|
||||
it("refuses without auth:provide", () => {
|
||||
grants.set("demo", []);
|
||||
const ctx = contextFor([], { auth: { secretSchemes: ["op"] } });
|
||||
|
||||
expect(() =>
|
||||
ctx.credentials.registerSecretResolver("op", async () => ""),
|
||||
).toThrow(/auth:provide/);
|
||||
});
|
||||
|
||||
it("refuses a scheme not listed in contributes.auth.secretSchemes", () => {
|
||||
grants.set("demo", ["auth:provide"]);
|
||||
const ctx = contextFor(["auth:provide"], { auth: {} });
|
||||
|
||||
expect(() =>
|
||||
ctx.credentials.registerSecretResolver("op", async () => ""),
|
||||
).toThrow(/secretSchemes/);
|
||||
});
|
||||
|
||||
it("resolves through the registered scheme and audits the call", async () => {
|
||||
grants.set("demo", ["auth:provide"]);
|
||||
const ctx = contextFor(["auth:provide"], {
|
||||
auth: { secretSchemes: ["op"] },
|
||||
});
|
||||
|
||||
ctx.credentials.registerSecretResolver("op", async (userId, ref) => {
|
||||
state.resolverCalls.push({ userId, ref });
|
||||
return `resolved:${ref}`;
|
||||
});
|
||||
|
||||
const registration = requireSecretResolver("op");
|
||||
expect(registration.pluginId).toBe("demo");
|
||||
|
||||
const value = await registration.resolve("alice", "op://v/i/f");
|
||||
expect(value).toBe("resolved:op://v/i/f");
|
||||
expect(state.resolverCalls[0]).toMatchObject({
|
||||
userId: "alice",
|
||||
ref: "op://v/i/f",
|
||||
});
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_secret_resolve",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("is removed when the plugin is deactivated", async () => {
|
||||
grants.set("demo", ["auth:provide"]);
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
name: "demo",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: ["auth:provide"],
|
||||
contributes: { auth: { secretSchemes: ["op"] } },
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle("demo", { activate: () => {} });
|
||||
const ctx = createPluginContext(manifest, handle);
|
||||
|
||||
ctx.credentials.registerSecretResolver("op", async () => "x");
|
||||
await handle.bag.disposeAll();
|
||||
|
||||
expect(getSecretResolver("op")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.credentials.listSshKeys", () => {
|
||||
const pair = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 });
|
||||
const derived = (() => {
|
||||
const parsed = ssh2.utils.parseKey(pair.private);
|
||||
const key = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
if (key instanceof Error) throw key;
|
||||
return `${key.type} ${key.getPublicSSH().toString("base64")}`;
|
||||
})();
|
||||
|
||||
beforeEach(() => {
|
||||
state.userCredentials.set("owner", [
|
||||
{ id: 1, name: "stored", authType: "key", publicKey: "ssh-ed25519 AAAA" },
|
||||
{ id: 2, name: "derived", authType: "key", privateKey: pair.private },
|
||||
{ id: 3, name: "pw", authType: "password", password: "secret" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses without credentials:use", async () => {
|
||||
grants.set("demo", []);
|
||||
const ctx = contextFor([]);
|
||||
await expect(
|
||||
runAsActor("owner", "request", () => ctx.credentials.listSshKeys()),
|
||||
).rejects.toThrow(/credentials:use/);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_list_keys",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses with no acting user", async () => {
|
||||
grants.set("demo", ["credentials:use"]);
|
||||
const ctx = contextFor(["credentials:use"]);
|
||||
await expect(ctx.credentials.listSshKeys()).rejects.toThrow(/acting user/);
|
||||
});
|
||||
|
||||
it("refuses without the core credentials.view permission", async () => {
|
||||
grants.set("demo", ["credentials:use"]);
|
||||
state.corePermissions.delete("credentials.view");
|
||||
const ctx = contextFor(["credentials:use"]);
|
||||
await expect(
|
||||
runAsActor("owner", "request", () => ctx.credentials.listSshKeys()),
|
||||
).rejects.toThrow(/credentials.view/);
|
||||
});
|
||||
|
||||
it("lists only the actor's key credentials, public half only", async () => {
|
||||
grants.set("demo", ["credentials:use"]);
|
||||
const ctx = contextFor(["credentials:use"]);
|
||||
const keys = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.listSshKeys(),
|
||||
);
|
||||
expect(keys).toEqual([
|
||||
{ id: 1, name: "stored", username: null, publicKey: "ssh-ed25519 AAAA" },
|
||||
{ id: 2, name: "derived", username: null, publicKey: derived },
|
||||
]);
|
||||
expect(JSON.stringify(keys)).not.toContain("PRIVATE KEY");
|
||||
|
||||
const other = await runAsActor("someone", "request", () =>
|
||||
ctx.credentials.listSshKeys(),
|
||||
);
|
||||
expect(other).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.credentials.createSshKey", () => {
|
||||
const input = {
|
||||
name: "Generated",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||
publicKey: "ssh-ed25519 AAAA",
|
||||
keyType: "ssh-ed25519",
|
||||
};
|
||||
|
||||
it("refuses without credentials:write", async () => {
|
||||
grants.set("demo", ["credentials:use"]);
|
||||
const ctx = contextFor(["credentials:use"]);
|
||||
await expect(
|
||||
runAsActor("owner", "request", () => ctx.credentials.createSshKey(input)),
|
||||
).rejects.toThrow(/credentials:write/);
|
||||
expect(state.savedCredentials).toHaveLength(0);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_create_key",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses without the core credentials.create permission", async () => {
|
||||
grants.set("demo", ["credentials:write"]);
|
||||
state.corePermissions.delete("credentials.create");
|
||||
const ctx = contextFor(["credentials:write"]);
|
||||
await expect(
|
||||
runAsActor("owner", "request", () => ctx.credentials.createSshKey(input)),
|
||||
).rejects.toThrow(/credentials.create/);
|
||||
expect(state.savedCredentials).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("saves a key credential owned by the actor", async () => {
|
||||
grants.set("demo", ["credentials:write"]);
|
||||
const ctx = contextFor(["credentials:write"]);
|
||||
const result = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.createSshKey({ ...input, username: "root" }),
|
||||
);
|
||||
expect(result).toEqual({ id: 100 });
|
||||
expect(state.savedCredentials[0]).toMatchObject({
|
||||
userId: "owner",
|
||||
authType: "key",
|
||||
name: "Generated",
|
||||
username: "root",
|
||||
privateKey: input.privateKey,
|
||||
publicKey: input.publicKey,
|
||||
detectedKeyType: "ssh-ed25519",
|
||||
});
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_create_key",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* ctx.db and the kv caps.
|
||||
*
|
||||
* db:own was in the catalog from A1 but nothing checked it. These assert that
|
||||
* it is checked where the privileged thing happens, and audited either way.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
const kvStore = new Map<string, string>();
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentPluginStorageRepository: () => ({
|
||||
get: async (pluginId: string, key: string) =>
|
||||
kvStore.get(`${pluginId}:${key}`) ?? null,
|
||||
set: async (pluginId: string, key: string, value: string) => {
|
||||
kvStore.set(`${pluginId}:${key}`, value);
|
||||
},
|
||||
delete: async (pluginId: string, key: string) =>
|
||||
kvStore.delete(`${pluginId}:${key}`),
|
||||
listKeys: async (pluginId: string) =>
|
||||
[...kvStore.keys()]
|
||||
.filter((key) => key.startsWith(`${pluginId}:`))
|
||||
.map((key) => key.slice(pluginId.length + 1)),
|
||||
countKeys: async (pluginId: string) =>
|
||||
[...kvStore.keys()].filter((key) => key.startsWith(`${pluginId}:`))
|
||||
.length,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({ marker: "db-handle" }),
|
||||
}));
|
||||
|
||||
const saves: string[] = [];
|
||||
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
forceSave: async (reason: string) => {
|
||||
saves.push(reason);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/schema.js", () => ({
|
||||
users: { marker: "users" },
|
||||
hosts: { marker: "hosts" },
|
||||
roles: { marker: "roles" },
|
||||
userRoles: { marker: "userRoles" },
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { resetPluginData } from "../../plugins/data.js";
|
||||
import {
|
||||
resetSyncRegistry,
|
||||
listEntityTypes,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
|
||||
return {
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
}
|
||||
|
||||
function contextFor(pluginId: string, capabilities: string[]) {
|
||||
const manifest = manifestFor(pluginId, capabilities);
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return { ctx: createPluginContext(manifest, handle), handle };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
kvStore.clear();
|
||||
resetPluginData();
|
||||
resetSyncRegistry();
|
||||
invalidatePluginPermissionCache();
|
||||
delete process.env.PLUGIN_MAX_KV_KEYS;
|
||||
delete process.env.DATABASE_DIALECT;
|
||||
saves.length = 0;
|
||||
});
|
||||
|
||||
describe("ctx.db.persist", () => {
|
||||
it("flushes SQLite to disk under the plugin's name", async () => {
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
await ctx.db.persist();
|
||||
|
||||
expect(saves).toEqual(["plugin_demo_write"]);
|
||||
expect(ctx.db.dialect).toBe("sqlite");
|
||||
});
|
||||
|
||||
it("does nothing on an engine that is already durable", async () => {
|
||||
process.env.DATABASE_DIALECT = "postgres";
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
await ctx.db.persist();
|
||||
|
||||
expect(saves).toEqual([]);
|
||||
expect(ctx.db.dialect).toBe("postgres");
|
||||
});
|
||||
|
||||
it("refuses without db:own", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.db.persist()).rejects.toThrow(/db:own/);
|
||||
expect(saves).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.db capability", () => {
|
||||
it("refuses define without db:own", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(
|
||||
ctx.db.define({ name: "thing", columns: {}, indexes: [] }),
|
||||
).rejects.toThrow(/db:own/);
|
||||
});
|
||||
|
||||
it("refuses define when granted but not declared", async () => {
|
||||
// A grant for something the manifest never declared is ignored, so
|
||||
// widening a plugin's reach always needs a manifest the user can see.
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(
|
||||
ctx.db.define({ name: "thing", columns: {}, indexes: [] }),
|
||||
).rejects.toThrow(/db:own/);
|
||||
});
|
||||
|
||||
it("refuses the client handle without db:own", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.db.client()).rejects.toThrow(/db:own/);
|
||||
});
|
||||
|
||||
it("allows define when declared and granted", async () => {
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
const table = await ctx.db.define({
|
||||
name: "thing",
|
||||
columns: { id: { type: "id", primaryKey: true, notNull: true } },
|
||||
indexes: [],
|
||||
});
|
||||
|
||||
expect(table).toBeTruthy();
|
||||
});
|
||||
|
||||
it("audits a refused call as well as an allowed one", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.db.client()).rejects.toThrow();
|
||||
|
||||
const entry = auditEntries.find((row) => row.action === "plugin_db_client");
|
||||
expect(entry).toMatchObject({
|
||||
success: false,
|
||||
username: "plugin:demo",
|
||||
resourceId: "demo",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses core refs with only db:own", async () => {
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
await expect(ctx.db.refs()).rejects.toThrow(/db:core-refs/);
|
||||
});
|
||||
|
||||
it("hands back refs to the core tables a plugin may join", async () => {
|
||||
grants.set("demo", ["db:core-refs"]);
|
||||
const { ctx } = contextFor("demo", ["db:core-refs"]);
|
||||
|
||||
const refs = (await ctx.db.refs()) as Record<string, unknown>;
|
||||
|
||||
expect(Object.keys(refs).sort()).toEqual([
|
||||
"hosts",
|
||||
"roles",
|
||||
"userRoles",
|
||||
"users",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.kv limits", () => {
|
||||
it("refuses a key over the length cap", async () => {
|
||||
grants.set("demo", ["kv:own"]);
|
||||
const { ctx } = contextFor("demo", ["kv:own"]);
|
||||
|
||||
await expect(ctx.kv.set("k".repeat(129), 1)).rejects.toThrow(
|
||||
/at most 128 characters/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a value over the size cap", async () => {
|
||||
grants.set("demo", ["kv:own"]);
|
||||
const { ctx } = contextFor("demo", ["kv:own"]);
|
||||
|
||||
await expect(ctx.kv.set("big", "x".repeat(300_000))).rejects.toThrow(
|
||||
/byte limit/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a new key past the key-count cap", async () => {
|
||||
process.env.PLUGIN_MAX_KV_KEYS = "2";
|
||||
grants.set("demo", ["kv:own"]);
|
||||
const { ctx } = contextFor("demo", ["kv:own"]);
|
||||
|
||||
await ctx.kv.set("a", 1);
|
||||
await ctx.kv.set("b", 2);
|
||||
|
||||
await expect(ctx.kv.set("c", 3)).rejects.toThrow(/key limit/);
|
||||
});
|
||||
|
||||
it("still allows overwriting an existing key when full", async () => {
|
||||
// Only a new key grows the table, so a full store is not read-only.
|
||||
process.env.PLUGIN_MAX_KV_KEYS = "2";
|
||||
grants.set("demo", ["kv:own"]);
|
||||
const { ctx } = contextFor("demo", ["kv:own"]);
|
||||
await ctx.kv.set("a", 1);
|
||||
await ctx.kv.set("b", 2);
|
||||
|
||||
await expect(ctx.kv.set("a", 99)).resolves.toBeUndefined();
|
||||
expect(await ctx.kv.get("a")).toBe(99);
|
||||
});
|
||||
|
||||
it("counts each plugin's keys separately", async () => {
|
||||
process.env.PLUGIN_MAX_KV_KEYS = "1";
|
||||
grants.set("one", ["kv:own"]);
|
||||
grants.set("two", ["kv:own"]);
|
||||
|
||||
await contextFor("one", ["kv:own"]).ctx.kv.set("a", 1);
|
||||
|
||||
await expect(
|
||||
contextFor("two", ["kv:own"]).ctx.kv.set("a", 1),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.sync", () => {
|
||||
it("registers an entity a plugin owns", () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
ctx.sync.registerEntity({ type: "demoThings", table: {}, order: 200 });
|
||||
|
||||
expect(listEntityTypes()).toContain("demoThings");
|
||||
});
|
||||
|
||||
it("drops the registration when the plugin is disposed", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx, handle } = contextFor("demo", []);
|
||||
ctx.sync.registerEntity({ type: "demoThings", table: {} });
|
||||
|
||||
await handle.bag.disposeAll();
|
||||
|
||||
expect(listEntityTypes()).not.toContain("demoThings");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* ctx.desktop.openIsolatedWindow: desktop:window is checked and audited, and
|
||||
* the call only reaches Electron's main process when the backend is actually
|
||||
* running embedded in it.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
const bridge = vi.hoisted(() => ({
|
||||
available: false,
|
||||
requestFromElectronMain: vi.fn(async () => ({ success: true as const })),
|
||||
}));
|
||||
vi.mock("../../utils/electron-ipc-bridge.js", () => ({
|
||||
isElectronIpcAvailable: () => bridge.available,
|
||||
requestFromElectronMain: bridge.requestFromElectronMain,
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
|
||||
return {
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
}
|
||||
|
||||
function contextFor(pluginId: string, capabilities: string[]) {
|
||||
const manifest = manifestFor(pluginId, capabilities);
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return { ctx: createPluginContext(manifest, handle), handle };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
bridge.available = false;
|
||||
bridge.requestFromElectronMain.mockClear();
|
||||
});
|
||||
|
||||
describe("ctx.desktop.openIsolatedWindow", () => {
|
||||
it("refuses without desktop:window, before reaching Electron", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(
|
||||
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
|
||||
).rejects.toThrow(/desktop:window/);
|
||||
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_open_isolated_window",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses when granted but not declared in the manifest", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(
|
||||
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
|
||||
).rejects.toThrow(/desktop:window/);
|
||||
});
|
||||
|
||||
it("refuses outside the desktop app even with the capability", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
bridge.available = false;
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
await expect(
|
||||
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
|
||||
).rejects.toThrow(/desktop app/);
|
||||
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("relays to Electron main and audits success", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
bridge.available = true;
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
const request = { url: "https://example.test", title: "Example" };
|
||||
await expect(ctx.desktop.openIsolatedWindow(request)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
|
||||
"open-isolated-window",
|
||||
request,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_open_isolated_window",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.desktop.launchExternalClient", () => {
|
||||
const request = {
|
||||
protocol: "rdp",
|
||||
host: "win.example.test",
|
||||
port: 3389,
|
||||
username: "bob",
|
||||
};
|
||||
|
||||
it("refuses without desktop:window", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow(
|
||||
/desktop:window/,
|
||||
);
|
||||
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_launch_external_client",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses outside the desktop app", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
expect(ctx.desktop.available()).toBe(false);
|
||||
await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow(
|
||||
/desktop app/,
|
||||
);
|
||||
});
|
||||
|
||||
it("relays to Electron main and audits success", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
bridge.available = true;
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
expect(ctx.desktop.available()).toBe(true);
|
||||
await expect(ctx.desktop.launchExternalClient(request)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
|
||||
"launch-external-client",
|
||||
request,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_launch_external_client",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,682 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
actor: undefined as string | undefined,
|
||||
access: {
|
||||
hasAccess: true,
|
||||
isOwner: true,
|
||||
isShared: false,
|
||||
} as Record<string, unknown>,
|
||||
ownedHosts: [] as Array<Record<string, unknown>>,
|
||||
sharedRows: [] as Array<Record<string, unknown>>,
|
||||
visibleGrants: [] as Array<{ hostId: number }>,
|
||||
ownerId: "user-1",
|
||||
hostById: null as Record<string, unknown> | null,
|
||||
upserts: [] as Array<Record<string, unknown>>,
|
||||
created: [] as Array<Record<string, unknown>>,
|
||||
updates: [] as Array<Record<string, unknown>>,
|
||||
updateResult: null as Record<string, unknown> | null,
|
||||
users: [] as Array<{ id: string; username: string }>,
|
||||
activities: [] as Array<Record<string, unknown>>,
|
||||
sessions: [] as number[],
|
||||
released: [] as number[],
|
||||
importRows: [] as Array<{ hostId: number; row: Record<string, unknown> }>,
|
||||
statusReports: [] as Array<Record<string, unknown>>,
|
||||
statusPorts: [] as string[],
|
||||
statusPortsRemoved: [] as string[],
|
||||
userPermissions: new Set<string>(),
|
||||
deleted: [] as Array<{ userId: string; hostId: number }>,
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
displayName: string | null;
|
||||
isSystem: boolean;
|
||||
}>,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
capabilityRefused: (pluginId: string, capability: string) =>
|
||||
new PluginCapabilityError(pluginId, capability),
|
||||
};
|
||||
});
|
||||
|
||||
/** The access check behind trackSession and reportLogin is async. */
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async () => h.access,
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
h.userPermissions.has(permission),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listHosts: async () => [],
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostsByUserId: async () => h.ownedHosts,
|
||||
listHostRowsForAccessList: async () => h.sharedRows,
|
||||
findHostOwnerId: async () => h.ownerId,
|
||||
findHostById: async () => h.hostById,
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
listUserRoleIds: async () => [],
|
||||
listRoles: async () => h.roles,
|
||||
}),
|
||||
createCurrentRbacAccessRepository: () => ({
|
||||
listVisibleHostAccessEntries: async () => h.visibleGrants,
|
||||
upsertHostAccess: async (input: Record<string, unknown>) => {
|
||||
h.upserts.push(input);
|
||||
return { id: h.upserts.length, created: true };
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => h.users,
|
||||
}),
|
||||
createCurrentHostRepository: () => ({
|
||||
createEncryptedForUser: async (
|
||||
userId: string,
|
||||
host: Record<string, unknown>,
|
||||
) => {
|
||||
const created = { id: 99, ...host, userId };
|
||||
h.created.push(created);
|
||||
return created;
|
||||
},
|
||||
updateEncryptedForUser: async (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
patch: Record<string, unknown>,
|
||||
) => {
|
||||
h.updates.push({ userId, hostId, patch });
|
||||
if (!h.updateResult) return null;
|
||||
return { ...h.updateResult, ...patch, id: hostId, userId };
|
||||
},
|
||||
listDecryptedByUserId: async () => h.ownedHosts,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../services/recent-activity.js", () => ({
|
||||
recordRecentActivity: async (
|
||||
userId: string,
|
||||
entry: Record<string, unknown>,
|
||||
) => {
|
||||
h.activities.push({ userId, ...entry });
|
||||
return { status: "logged", id: 1 };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/host-session-status.js", () => ({
|
||||
hostSessionStatus: {
|
||||
register: (hostId: number) => {
|
||||
h.sessions.push(hostId);
|
||||
return () => h.released.push(hostId);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||
hostStatusService: {
|
||||
get: (hostId: number) =>
|
||||
hostId === 4 ? { status: "reachable", lastChecked: "t" } : null,
|
||||
check: async (hostId: number) => ({
|
||||
status: "offline",
|
||||
lastChecked: String(hostId),
|
||||
}),
|
||||
reportLogin: (hostId: number, outcome: Record<string, unknown>) =>
|
||||
h.statusReports.push({ hostId, ...outcome }),
|
||||
registerPort: (connectionType: string) => {
|
||||
h.statusPorts.push(connectionType);
|
||||
return () => h.statusPortsRemoved.push(connectionType);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
loadHostPluginSettings: async (hostIds: number[]) =>
|
||||
new Map(
|
||||
hostIds.map((id) => [
|
||||
id,
|
||||
{ fixture: { mine: `own-${id}` }, other: { theirs: true } },
|
||||
]),
|
||||
),
|
||||
checkHostPluginSettingsInput: (
|
||||
input: Record<string, Record<string, unknown>>,
|
||||
) => {
|
||||
const errors: string[] = [];
|
||||
const writes: Array<{
|
||||
manifest: { id: string };
|
||||
values: Record<string, unknown>;
|
||||
}> = [];
|
||||
const skipped: string[] = [];
|
||||
for (const [pluginId, values] of Object.entries(input)) {
|
||||
if (pluginId === "off") {
|
||||
skipped.push(pluginId);
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(values)) {
|
||||
if (key === "undeclared") errors.push(`${pluginId}.${key}`);
|
||||
}
|
||||
writes.push({ manifest: { id: pluginId }, values });
|
||||
}
|
||||
return { writes, skipped, errors };
|
||||
},
|
||||
writeHostPluginSettings: async (
|
||||
manifest: { id: string },
|
||||
hostId: number,
|
||||
values: Record<string, unknown>,
|
||||
) => {
|
||||
h.importRows.push({ hostId, row: { [manifest.id]: values } });
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
snapshotForUser: async () => {},
|
||||
snapshotForRole: async () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/defaults/index.js", () => ({
|
||||
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
||||
applyDefaultsAfterHostWrite: async () => {},
|
||||
}));
|
||||
vi.mock("../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/delete-host.js", () => ({
|
||||
deleteOwnedHost: async (userId: string, hostId: number) => {
|
||||
h.deleted.push({ userId, hostId });
|
||||
return hostId === 404 ? null : { id: hostId, name: "gone" };
|
||||
},
|
||||
}));
|
||||
|
||||
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
function manifest(capabilities: string[]) {
|
||||
return {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.granted = new Set();
|
||||
h.actor = "user-1";
|
||||
h.access = { hasAccess: true, isOwner: true, isShared: false };
|
||||
h.ownedHosts = [];
|
||||
h.sharedRows = [];
|
||||
h.visibleGrants = [];
|
||||
h.ownerId = "user-1";
|
||||
h.hostById = null;
|
||||
h.upserts = [];
|
||||
h.created = [];
|
||||
h.updates = [];
|
||||
h.updateResult = null;
|
||||
h.users = [];
|
||||
h.roles = [];
|
||||
h.userPermissions = new Set();
|
||||
h.deleted = [];
|
||||
});
|
||||
|
||||
describe("ctx.hosts", () => {
|
||||
it("refuses list/get/checkAccess without hosts:read", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
||||
await expect(hosts.list()).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(hosts.get(1)).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(hosts.checkAccess(1, "view")).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("lists owned and shared hosts once granted hosts:read", async () => {
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
h.ownedHosts = [
|
||||
{
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
name: "own",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: "prod",
|
||||
folder: null,
|
||||
authType: "password",
|
||||
},
|
||||
];
|
||||
h.sharedRows = [
|
||||
{
|
||||
id: 2,
|
||||
userId: "user-2",
|
||||
name: "shared",
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "key",
|
||||
},
|
||||
];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const list = await hosts.list();
|
||||
expect(list.map((h2) => h2.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("checkAccess reports the granted level", async () => {
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
h.access = {
|
||||
hasAccess: true,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
permissionLevel: "edit",
|
||||
expiresAt: null,
|
||||
};
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const access = await hosts.checkAccess(1, "edit");
|
||||
expect(access).toMatchObject({ hasAccess: true, permissionLevel: "edit" });
|
||||
});
|
||||
|
||||
it("refuses share, listUsers, listRoles without hosts:write, auditing the refusal", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
||||
await expect(
|
||||
hosts.share(1, [{ type: "user", id: "user-2" }], "view"),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(hosts.listUsers()).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
await expect(hosts.listRoles()).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"hosts_share",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shares a host it manages and snapshots secrets per target", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.access = { hasAccess: true, isOwner: true, isShared: false };
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const result = await hosts.share(
|
||||
5,
|
||||
[{ type: "user", id: "user-2" }],
|
||||
"view",
|
||||
24,
|
||||
);
|
||||
expect(result).toEqual({ hostId: 5, shared: true });
|
||||
expect(h.upserts).toHaveLength(1);
|
||||
expect(h.upserts[0]).toMatchObject({
|
||||
hostId: 5,
|
||||
permissionLevel: "view",
|
||||
targetType: "user",
|
||||
targetUserId: "user-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to share a host the caller does not manage", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.access = { hasAccess: false, isOwner: false, isShared: false };
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const result = await hosts.share(
|
||||
5,
|
||||
[{ type: "user", id: "user-2" }],
|
||||
"view",
|
||||
);
|
||||
expect(result).toEqual({ hostId: 5, shared: false, reason: "forbidden" });
|
||||
});
|
||||
|
||||
it("lists non-system roles and users once granted hosts:write", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.users = [{ id: "user-1", username: "alice" }];
|
||||
h.roles = [
|
||||
{ id: 1, name: "admin", displayName: "Admin", isSystem: true },
|
||||
{ id: 2, name: "auditor", displayName: "Auditor", isSystem: false },
|
||||
];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.listUsers()).toEqual([
|
||||
{ id: "user-1", username: "alice" },
|
||||
]);
|
||||
expect(await hosts.listRoles()).toEqual([
|
||||
{ id: 2, name: "auditor", displayName: "Auditor" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses create/update/listOwned without hosts:write", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
||||
await expect(
|
||||
hosts.create({
|
||||
name: "n",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(hosts.update(1, { name: "x" })).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
await expect(hosts.listOwned()).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"hosts_create",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a host once granted hosts:write", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.importRows = [];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const created = await hosts.create({
|
||||
name: "pve-guest",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
tags: ["a", "b"],
|
||||
jumpHosts: [{ hostId: 3 }],
|
||||
pluginSettings: { "remote-desktop": { enableRdp: true } },
|
||||
});
|
||||
expect(created).toMatchObject({
|
||||
id: 99,
|
||||
name: "pve-guest",
|
||||
userId: "user-1",
|
||||
tags: "a,b",
|
||||
jumpHosts: [{ hostId: 3 }],
|
||||
pluginSettings: { mine: "own-99" },
|
||||
});
|
||||
expect(h.created[0]).toMatchObject({
|
||||
tags: "a,b",
|
||||
jumpHosts: '[{"hostId":3}]',
|
||||
});
|
||||
// Another plugin's settings go to that plugin, validated by core.
|
||||
expect(h.importRows).toEqual([
|
||||
{ hostId: 99, row: { "remote-desktop": { enableRdp: true } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a field the SDK does not take instead of dropping it", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(
|
||||
hosts.create({
|
||||
name: "n",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
enableRdp: true,
|
||||
} as never),
|
||||
).rejects.toThrow(/enableRdp/);
|
||||
await expect(
|
||||
hosts.update(5, { userId: "someone-else" } as never),
|
||||
).rejects.toThrow(/userId/);
|
||||
await expect(
|
||||
hosts.update(5, { pluginSettings: { docker: { undeclared: 1 } } }),
|
||||
).rejects.toThrow(/undeclared/);
|
||||
expect(h.created).toHaveLength(0);
|
||||
expect(h.updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("builds the record field by field, with no secret and no other plugin's settings", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.ownedHosts = [
|
||||
{
|
||||
id: 4,
|
||||
userId: "user-1",
|
||||
name: "own",
|
||||
ip: "10.0.0.4",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret",
|
||||
key: "PRIVATE",
|
||||
autostartPassword: "x",
|
||||
terminalConfig: '{"keepaliveInterval":5,"fontSize":14}',
|
||||
enableDocker: true,
|
||||
jumpHosts: "[]",
|
||||
},
|
||||
];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const [record] = await hosts.listOwned();
|
||||
const loose = record as unknown as Record<string, unknown>;
|
||||
for (const field of [
|
||||
"password",
|
||||
"key",
|
||||
"autostartPassword",
|
||||
"terminalConfig",
|
||||
"enableDocker",
|
||||
]) {
|
||||
expect(loose).not.toHaveProperty(field);
|
||||
}
|
||||
expect(record.sshOptions).toEqual({ keepaliveInterval: 5 });
|
||||
expect(record.pluginSettings).toEqual({ mine: "own-4" });
|
||||
expect(record.status).toEqual({ status: "reachable", lastChecked: "t" });
|
||||
expect(record.enableSsh).toBe(true);
|
||||
});
|
||||
|
||||
it("updates a host once granted hosts:write", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.updateResult = { id: 5, name: "old" };
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const updated = await hosts.update(5, { name: "new" });
|
||||
expect(updated).toMatchObject({ id: 5, name: "new" });
|
||||
expect(h.updates).toEqual([
|
||||
{ userId: "user-1", hostId: 5, patch: { name: "new" } },
|
||||
]);
|
||||
expect(updated?.pluginSettings).toEqual({ mine: "own-5" });
|
||||
});
|
||||
|
||||
it("returns null updating a host that does not exist for this user", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.update(404, { name: "x" })).toBeNull();
|
||||
});
|
||||
|
||||
it("lists owned hosts decrypted once granted hosts:write", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.ownedHosts = [{ id: 1, userId: "user-1", name: "own" }];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.listOwned()).toMatchObject([{ id: 1, name: "own" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts.delete", () => {
|
||||
it("refuses without hosts:write and audits it", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
||||
await expect(hosts.delete(1)).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"hosts_delete",
|
||||
"host 1",
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
expect(h.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an acting user without hosts.delete", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(hosts.delete(1)).rejects.toThrow("may not delete hosts");
|
||||
expect(h.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("deletes the actor's own host through core's delete path", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.userPermissions = new Set(["hosts.delete"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.delete(7)).toBe(true);
|
||||
expect(await hosts.delete(404)).toBe(false);
|
||||
expect(h.deleted).toEqual([
|
||||
{ userId: "user-1", hostId: 7 },
|
||||
{ userId: "user-1", hostId: 404 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts session tracking and activity", () => {
|
||||
it("counts a live session through core and releases it", async () => {
|
||||
h.sessions = [];
|
||||
h.released = [];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const release = hosts.trackSession(4);
|
||||
await settle();
|
||||
expect(h.sessions).toEqual([4]);
|
||||
release();
|
||||
expect(h.released).toEqual([4]);
|
||||
});
|
||||
|
||||
it("refuses to track a session without hosts:read declared", () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest([]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() => hosts.trackSession(4)).toThrow(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("records activity for the acting user only", async () => {
|
||||
h.activities = [];
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
h.actor = "user-7";
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await hosts.recordActivity(4, "terminal", "web-01");
|
||||
expect(h.activities).toEqual([
|
||||
{ userId: "user-7", type: "terminal", hostId: 4, hostName: "web-01" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts.status", () => {
|
||||
it("reads and checks core's status with hosts:read", async () => {
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(hosts.status.get(4)).resolves.toEqual({
|
||||
status: "reachable",
|
||||
lastChecked: "t",
|
||||
});
|
||||
await expect(hosts.status.get(5)).resolves.toBeNull();
|
||||
await expect(hosts.status.check(5)).resolves.toEqual({
|
||||
status: "offline",
|
||||
lastChecked: "5",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses every status member without hosts:read", async () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest([]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(hosts.status.get(4)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(() => hosts.status.reportLogin(4, { ok: true })).toThrow(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(() => hosts.status.registerPort("rdp", () => 3389)).toThrow(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
await expect(hosts.status.check(4)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes login reports on to core", async () => {
|
||||
h.statusReports = [];
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true });
|
||||
await settle();
|
||||
expect(h.statusReports).toEqual([
|
||||
{ hostId: 4, ok: false, hostKeyChanged: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes a registered port when the plugin is disposed", async () => {
|
||||
h.statusPorts = [];
|
||||
h.statusPortsRemoved = [];
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const bag = new DisposableBag("fixture");
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
hosts.status.registerPort("rdp", () => 3389);
|
||||
expect(h.statusPorts).toEqual(["rdp"]);
|
||||
await bag.disposeAll();
|
||||
expect(h.statusPortsRemoved).toEqual(["rdp"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* ctx.notify and ctx.fetch: each is gated on its capability and audited.
|
||||
* notify resolves the audience in core and hands the hub plain user ids, and
|
||||
* fetch goes through the SSRF guard with only the hosts the caller listed.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
PluginNotification,
|
||||
PluginNotifyHub,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
users: [
|
||||
{ id: "alice", isAdmin: true },
|
||||
{ id: "bob", isAdmin: false },
|
||||
{ id: "carol", isAdmin: false },
|
||||
],
|
||||
permissions: new Map<string, string[]>([
|
||||
["bob", ["docker.view"]],
|
||||
["carol", ["docker.view"]],
|
||||
]),
|
||||
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
|
||||
lastTls: null as Record<string, unknown> | null,
|
||||
lastIdleTimeout: undefined as number | undefined,
|
||||
lastSignal: null as AbortSignal | null,
|
||||
lastRedirect: undefined as RequestRedirect | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => state.users,
|
||||
findById: async (id: string) =>
|
||||
state.users.find((user) => user.id === id) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async (userId: string, permission: string) =>
|
||||
state.permissions.get(userId)?.includes(permission) ?? false,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: async (
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
allowlist: readonly string[],
|
||||
tls: Record<string, unknown> = {},
|
||||
idleTimeoutMs?: number,
|
||||
) => {
|
||||
state.lastTls = tls;
|
||||
state.lastIdleTimeout = idleTimeoutMs;
|
||||
state.lastRedirect = init.redirect;
|
||||
state.lastSignal = init.signal ?? null;
|
||||
state.fetchCalls.push({ url, allowlist });
|
||||
return new Response("ok", { status: 200 });
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import { clearNotifyHub } from "../../plugins/notify-hub.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(
|
||||
capabilities: string[],
|
||||
id = "demo",
|
||||
granted: string[] = capabilities,
|
||||
) {
|
||||
grants.set(id, granted);
|
||||
const manifest = {
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle(id, { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
function fakeHub() {
|
||||
const calls: Array<{
|
||||
source: string;
|
||||
recipients: string[];
|
||||
notification: PluginNotification;
|
||||
}> = [];
|
||||
const hub: PluginNotifyHub = {
|
||||
deliver: async (input) => {
|
||||
calls.push(input);
|
||||
return {
|
||||
recipients: input.recipients.length,
|
||||
delivered: 0,
|
||||
failures: [],
|
||||
};
|
||||
},
|
||||
channels: async (userId) => [
|
||||
{ id: 1, name: `channel of ${userId}`, type: "webhook", enabled: true },
|
||||
],
|
||||
};
|
||||
return { hub, calls };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
clearNotifyHub();
|
||||
state.fetchCalls.length = 0;
|
||||
});
|
||||
|
||||
describe("ctx.notify", () => {
|
||||
it("refuses without notify:send and audits the refusal", async () => {
|
||||
const ctx = contextFor([]);
|
||||
await expect(
|
||||
runAsActor("alice", "request", () => ctx.notify.channels()),
|
||||
).rejects.toThrow(/notify:send/);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_notify_channels",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a send with neither an acting user nor an audience", async () => {
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
await expect(ctx.notify.send({ title: "t" })).rejects.toThrow(
|
||||
/acting user/,
|
||||
);
|
||||
});
|
||||
|
||||
it("reaches nobody while no hub is running", async () => {
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
const result = await runAsActor("alice", "request", () =>
|
||||
ctx.notify.send({ title: "Disk" }),
|
||||
);
|
||||
expect(result).toEqual({ recipients: 0, delivered: 0, failures: [] });
|
||||
expect(
|
||||
await runAsActor("alice", "request", () => ctx.notify.channels()),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("hands the hub the acting user and the sending plugin by default", async () => {
|
||||
const { hub, calls } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
|
||||
const result = await runAsActor("alice", "request", () =>
|
||||
ctx.notify.send({ title: "Disk", severity: "critical" }),
|
||||
);
|
||||
|
||||
expect(result.recipients).toBe(1);
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
source: "demo",
|
||||
recipients: ["alice"],
|
||||
notification: { title: "Disk", severity: "critical" },
|
||||
},
|
||||
]);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_notify_send",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a named user, admins and a permission in core", async () => {
|
||||
const { hub, calls } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
|
||||
await ctx.notify.send({ title: "a", audience: { userId: "bob" } });
|
||||
await ctx.notify.send({ title: "b", audience: "admins" });
|
||||
await ctx.notify.send({
|
||||
title: "c",
|
||||
audience: { permission: "docker.view" },
|
||||
});
|
||||
|
||||
expect(calls.map((call) => call.recipients)).toEqual([
|
||||
["bob"],
|
||||
["alice"],
|
||||
["bob", "carol"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not call the hub for an audience that names nobody", async () => {
|
||||
const { hub, calls } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
|
||||
const result = await ctx.notify.send({
|
||||
title: "x",
|
||||
audience: { userId: "ghost" },
|
||||
});
|
||||
|
||||
expect(result.recipients).toBe(0);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("asks the hub for the acting user's channels", async () => {
|
||||
const { hub } = fakeHub();
|
||||
contextFor(["notify:hub"], "hub").notify.serve(hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
expect(
|
||||
await runAsActor("bob", "request", () => ctx.notify.channels()),
|
||||
).toEqual([
|
||||
{ id: 1, name: "channel of bob", type: "webhook", enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("only serves a hub the manifest declares notify:hub for", () => {
|
||||
const { hub } = fakeHub();
|
||||
expect(() =>
|
||||
contextFor(["notify:send"], "rogue").notify.serve(hub),
|
||||
).toThrow(/notify:hub/);
|
||||
});
|
||||
|
||||
it("refuses a second hub from another plugin", () => {
|
||||
contextFor(["notify:hub"], "hub").notify.serve(fakeHub().hub);
|
||||
expect(() =>
|
||||
contextFor(["notify:hub"], "other").notify.serve(fakeHub().hub),
|
||||
).toThrow(/already/);
|
||||
});
|
||||
|
||||
it("stops using a hub once it is revoked or its grant is gone", async () => {
|
||||
const first = fakeHub();
|
||||
const revoke = contextFor(["notify:hub"], "hub").notify.serve(first.hub);
|
||||
const ctx = contextFor(["notify:send"]);
|
||||
revoke();
|
||||
await ctx.notify.send({ title: "x", audience: "admins" });
|
||||
expect(first.calls).toEqual([]);
|
||||
|
||||
const second = fakeHub();
|
||||
contextFor(["notify:hub"], "ungranted", []).notify.serve(second.hub);
|
||||
await ctx.notify.send({ title: "y", audience: "admins" });
|
||||
expect(second.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.fetch", () => {
|
||||
it("forwards manual redirect handling without enabling redirect following", async () => {
|
||||
const ctx = contextFor(["network:outbound"]);
|
||||
await ctx.fetch("https://example.com", { redirect: "manual" });
|
||||
expect(state.lastRedirect).toBe("manual");
|
||||
await ctx.fetch("https://example.com");
|
||||
expect(state.lastRedirect).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses without network:outbound", async () => {
|
||||
const ctx = contextFor([]);
|
||||
await expect(ctx.fetch("https://example.com")).rejects.toThrow(
|
||||
/network:outbound/,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_fetch",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("goes through the SSRF guard with the listed private hosts only", async () => {
|
||||
const ctx = contextFor(["network:outbound"]);
|
||||
const response = await ctx.fetch("https://ntfy.lan/topic", {
|
||||
method: "POST",
|
||||
body: "hi",
|
||||
allowPrivateHosts: ["NTFY.LAN"],
|
||||
});
|
||||
expect(await response.text()).toBe("ok");
|
||||
expect(state.fetchCalls).toEqual([
|
||||
{ url: "https://ntfy.lan/topic", allowlist: ["ntfy.lan"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes a pinned CA and the fingerprint bootstrap flag to the guard", async () => {
|
||||
const ctx = contextFor(["network:outbound"]);
|
||||
await ctx.fetch("https://ca.lan/root/abc", {
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
expect(state.lastTls).toEqual({ rejectUnauthorized: false });
|
||||
await ctx.fetch("https://ca.lan/provisioners", { tls: { ca: "PEM" } });
|
||||
expect(state.lastTls).toEqual({ ca: "PEM" });
|
||||
await ctx.fetch("https://example.com");
|
||||
expect(state.lastTls).toEqual({});
|
||||
});
|
||||
|
||||
it("hands the caller's timeout to the guard for the body idle timeout", async () => {
|
||||
const ctx = contextFor(["network:outbound"]);
|
||||
await ctx.fetch("https://example.com/stream", { timeoutMs: 600_000 });
|
||||
expect(state.lastIdleTimeout).toBe(600_000);
|
||||
});
|
||||
|
||||
it("aborts the request, and a streamed body, when the caller's signal fires", async () => {
|
||||
const ctx = contextFor(["network:outbound"]);
|
||||
const controller = new AbortController();
|
||||
await ctx.fetch("https://example.com/stream", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(state.lastSignal?.aborted).toBe(false);
|
||||
controller.abort();
|
||||
expect(state.lastSignal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({
|
||||
loader: {
|
||||
list: () => [
|
||||
{
|
||||
id: "alpha",
|
||||
source: "bundled",
|
||||
state: "active",
|
||||
manifest: { version: "1.2.0" },
|
||||
dir: "/secret/path",
|
||||
lastError: "boom",
|
||||
},
|
||||
{
|
||||
id: "beta",
|
||||
source: "user",
|
||||
state: "stopped",
|
||||
manifest: { version: "0.1.0" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createPluginPlugins } = await import("../../plugins/ctx-plugins.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
const manifest = (capabilities: string[]) =>
|
||||
({ id: "reader", capabilities }) as unknown as PluginManifest;
|
||||
|
||||
describe("ctx.plugins", () => {
|
||||
it("lists id, version, source and state only", async () => {
|
||||
const plugins = createPluginPlugins(manifest(["plugins:read"]));
|
||||
expect(await plugins.list()).toEqual([
|
||||
{ id: "alpha", version: "1.2.0", source: "bundled", state: "active" },
|
||||
{ id: "beta", version: "0.1.0", source: "user", state: "stopped" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses without plugins:read", async () => {
|
||||
const plugins = createPluginPlugins(manifest([]));
|
||||
await expect(plugins.list()).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({ granted: new Set<string>() }));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { createPluginProcess } = await import("../../plugins/ctx-process.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
const DECLARED = ["process:spawn", "network:outbound"];
|
||||
|
||||
function manifest(capabilities: string[] = DECLARED) {
|
||||
return {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never;
|
||||
}
|
||||
|
||||
function sha256(data: string): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let audits: Array<{ action: string; success: boolean }>;
|
||||
|
||||
function create(
|
||||
options: { capabilities?: string[]; fetch?: typeof fetch } = {},
|
||||
) {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const process = createPluginProcess({
|
||||
manifest: manifest(options.capabilities),
|
||||
bag,
|
||||
audit: async (action, _details, outcome) => {
|
||||
audits.push({ action, success: outcome.success });
|
||||
},
|
||||
binDir: () => path.join(dir, "bin"),
|
||||
fetch: options.fetch,
|
||||
});
|
||||
return { process, bag };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
h.granted = new Set(DECLARED);
|
||||
audits = [];
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "ctx-process-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("ctx.process.run", () => {
|
||||
it("runs a program and streams its output", async () => {
|
||||
const { process: proc } = create();
|
||||
const child = await proc.run(globalThis.process.execPath, [
|
||||
"-e",
|
||||
"process.stdout.write('hello'); process.stderr.write('oops')",
|
||||
]);
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.onStdout((chunk) => (out += chunk));
|
||||
child.onStderr((chunk) => (err += chunk));
|
||||
await expect(child.exited).resolves.toEqual({ code: 0, signal: null });
|
||||
expect(out).toBe("hello");
|
||||
expect(err).toBe("oops");
|
||||
expect(audits).toContainEqual({ action: "process_run", success: true });
|
||||
});
|
||||
|
||||
it("refuses without process:spawn, and audits the refusal", async () => {
|
||||
h.granted = new Set();
|
||||
const { process: proc } = create();
|
||||
await expect(
|
||||
proc.run(globalThis.process.execPath, ["-e", ""]),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
expect(audits).toContainEqual({ action: "process_run", success: false });
|
||||
});
|
||||
|
||||
it("kills what is still running on deactivate", async () => {
|
||||
const { process: proc, bag } = create();
|
||||
const child = await proc.run(globalThis.process.execPath, [
|
||||
"-e",
|
||||
"setInterval(() => {}, 1000)",
|
||||
]);
|
||||
await bag.disposeAll();
|
||||
const exit = await child.exited;
|
||||
expect(exit.code === null || exit.signal !== null).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.process.ensureBinary", () => {
|
||||
const contents = "#!/bin/sh\necho hi\n";
|
||||
|
||||
it("uses a prebuilt copy whose checksum matches, without downloading", async () => {
|
||||
const prebuilt = path.join(dir, "prebuilt-tool");
|
||||
await fs.writeFile(prebuilt, contents);
|
||||
const fetch = vi.fn();
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
prebuilt: [path.join(dir, "missing"), prebuilt],
|
||||
}),
|
||||
).resolves.toBe(prebuilt);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("downloads once, verifies it and reuses the copy", async () => {
|
||||
const fetch = vi.fn(async () => new Response(contents));
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
const spec = {
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
};
|
||||
const first = await proc.ensureBinary(spec);
|
||||
expect(first).toBe(path.join(dir, "bin", "tool"));
|
||||
expect(await fs.readFile(first, "utf8")).toBe(contents);
|
||||
await proc.ensureBinary(spec);
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("refuses a download whose checksum does not match", async () => {
|
||||
const fetch = vi.fn(async () => new Response("tampered"));
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
prebuilt: [],
|
||||
}),
|
||||
).rejects.toThrow(/Checksum mismatch/);
|
||||
await expect(fs.access(path.join(dir, "bin", "tool"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("needs network:outbound only to download", async () => {
|
||||
h.granted = new Set(["process:spawn"]);
|
||||
const { process: proc } = create({
|
||||
fetch: vi.fn(async () => new Response(contents)) as never,
|
||||
});
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("refuses plain http and odd file names", async () => {
|
||||
const { process: proc } = create({
|
||||
fetch: vi.fn(async () => new Response(contents)) as never,
|
||||
});
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "http://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
}),
|
||||
).rejects.toThrow(/https/);
|
||||
await expect(
|
||||
proc.ensureBinary({
|
||||
name: "../tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
}),
|
||||
).rejects.toThrow(/Invalid binary name/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* ctx.secrets.get/set/delete: a plugin's own per-user secrets. Gated on
|
||||
* secrets:own, scoped to the acting user, encrypted at rest, and the value
|
||||
* never reaches the audit log.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: new Map<string, { value: string | null; encrypted: boolean }>(),
|
||||
}));
|
||||
|
||||
const rowKey = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => `${pluginId}|${scope}|${scopeId}|${key}`;
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => {
|
||||
const row = state.rows.get(rowKey(pluginId, scope, scopeId, key));
|
||||
return row ? { ...row } : null;
|
||||
},
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
state.rows.set(rowKey(pluginId, scope, scopeId, key), {
|
||||
value,
|
||||
encrypted,
|
||||
});
|
||||
},
|
||||
delete: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => state.rows.delete(rowKey(pluginId, scope, scopeId, key)),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
|
||||
decryptSystemSecret: async (value: string) => value.replace(/^sysenc:/, ""),
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:"),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(capabilities: string[]) {
|
||||
grants.set("demo", capabilities);
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
name: "demo",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle("demo", { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
state.rows.clear();
|
||||
});
|
||||
|
||||
describe("ctx.secrets store", () => {
|
||||
it("refuses every call without secrets:own", async () => {
|
||||
const ctx = contextFor([]);
|
||||
await runAsActor("alice", "request", async () => {
|
||||
await expect(ctx.secrets.get("k")).rejects.toThrow(/secrets:own/);
|
||||
await expect(ctx.secrets.set("k", "v")).rejects.toThrow(/secrets:own/);
|
||||
await expect(ctx.secrets.delete("k")).rejects.toThrow(/secrets:own/);
|
||||
});
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_secret_delete",
|
||||
success: false,
|
||||
});
|
||||
expect(state.rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a call with no acting user", async () => {
|
||||
const ctx = contextFor(["secrets:own"]);
|
||||
await expect(ctx.secrets.set("k", "v")).rejects.toThrow(/acting user/);
|
||||
});
|
||||
|
||||
it("stores encrypted, per user, and never audits the value", async () => {
|
||||
const ctx = contextFor(["secrets:own"]);
|
||||
|
||||
await runAsActor("alice", "request", () =>
|
||||
ctx.secrets.set("provider:1", "sk-alice"),
|
||||
);
|
||||
|
||||
expect(state.rows.get("demo|secret|alice|provider:1")).toEqual({
|
||||
value: JSON.stringify("sysenc:sk-alice"),
|
||||
encrypted: true,
|
||||
});
|
||||
expect(
|
||||
await runAsActor("alice", "request", () => ctx.secrets.get("provider:1")),
|
||||
).toBe("sk-alice");
|
||||
expect(
|
||||
await runAsActor("bob", "request", () => ctx.secrets.get("provider:1")),
|
||||
).toBeNull();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_secret_set",
|
||||
success: true,
|
||||
});
|
||||
expect(JSON.stringify(auditEntries)).not.toContain("sk-alice");
|
||||
});
|
||||
|
||||
it("clears on null and on delete", async () => {
|
||||
const ctx = contextFor(["secrets:own"]);
|
||||
await runAsActor("alice", "request", async () => {
|
||||
await ctx.secrets.set("a", "1");
|
||||
await ctx.secrets.set("b", "2");
|
||||
await ctx.secrets.set("a", null);
|
||||
await ctx.secrets.delete("b");
|
||||
expect(await ctx.secrets.get("a")).toBeNull();
|
||||
expect(await ctx.secrets.get("b")).toBeNull();
|
||||
});
|
||||
expect(state.rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it("seals without an acting user and refuses without secrets:own", async () => {
|
||||
const denied = contextFor([]);
|
||||
await expect(denied.secrets.seal("v")).rejects.toThrow(/secrets:own/);
|
||||
await expect(denied.secrets.unseal("sysenc:v")).rejects.toThrow(
|
||||
/secrets:own/,
|
||||
);
|
||||
|
||||
const ctx = contextFor(["secrets:own"]);
|
||||
const sealed = await ctx.secrets.seal("totp-secret");
|
||||
expect(sealed).toBe("sysenc:totp-secret");
|
||||
expect(await ctx.secrets.unseal(sealed)).toBe("totp-secret");
|
||||
// A plain string was never sealed and must not read back as one.
|
||||
expect(await ctx.secrets.unseal("totp-secret")).toBeNull();
|
||||
expect(JSON.stringify(auditEntries)).not.toContain("totp-secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* ctx.services with named providers: a plugin may only register the names
|
||||
* its manifest lists, a consumer reaches one provider by name, and each is
|
||||
* disposed with the plugin that provided it.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: async () => {} }));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import {
|
||||
clearServiceRegistry,
|
||||
getRegistration,
|
||||
} from "../../plugins/service-registry.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(pluginId: string, names?: string[]) {
|
||||
const manifest = {
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
provides: [
|
||||
{
|
||||
service: "sample.live",
|
||||
version: "1.0.0",
|
||||
permission: `${pluginId}.use`,
|
||||
...(names ? { names } : {}),
|
||||
},
|
||||
],
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return { ctx: createPluginContext(manifest, handle), handle };
|
||||
}
|
||||
|
||||
afterEach(() => clearServiceRegistry());
|
||||
|
||||
describe("ctx.services named providers", () => {
|
||||
it("registers a declared name and lists it", () => {
|
||||
const { ctx } = contextFor("terminal-like", ["ssh"]);
|
||||
ctx.services.provide("sample.live", { get: () => 1 }, { name: "ssh" });
|
||||
|
||||
expect(getRegistration("sample.live", "ssh")?.pluginId).toBe(
|
||||
"terminal-like",
|
||||
);
|
||||
expect(ctx.services.providers("sample.live")).toEqual(["ssh"]);
|
||||
});
|
||||
|
||||
it("refuses a name the manifest does not list", () => {
|
||||
const { ctx } = contextFor("terminal-like", ["ssh"]);
|
||||
expect(() =>
|
||||
ctx.services.provide("sample.live", {}, { name: "rdp" }),
|
||||
).toThrow(/provides\[\]\.names does not list it/);
|
||||
expect(() => ctx.services.provide("sample.live", {})).toThrow(
|
||||
/does not list it/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a name when the manifest declares an unnamed service", () => {
|
||||
const { ctx } = contextFor("plain");
|
||||
expect(() =>
|
||||
ctx.services.provide("sample.live", {}, { name: "ssh" }),
|
||||
).toThrow(/does not list it/);
|
||||
});
|
||||
|
||||
it("reaches one provider by name through a handle", () => {
|
||||
const { ctx } = contextFor("terminal-like", ["ssh"]);
|
||||
ctx.services.provide("sample.live", { get: () => 1 }, { name: "ssh" });
|
||||
|
||||
const named = ctx.services.get<{ get: () => Promise<number> }>(
|
||||
"sample.live",
|
||||
{ provider: "ssh" },
|
||||
);
|
||||
const other = ctx.services.get("sample.live", { provider: "rdp" });
|
||||
expect("get" in named).toBe(true);
|
||||
expect("get" in other).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,612 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
connects: [] as Array<{ target: unknown; options: Record<string, unknown> }>,
|
||||
factors: [] as Array<{ userId: string; pluginId: string; factorId: string }>,
|
||||
actor: undefined as string | undefined,
|
||||
cleared: [] as string[],
|
||||
clearedAll: 0,
|
||||
pooled: [] as string[],
|
||||
sessionId: undefined as string | undefined,
|
||||
revoked: [] as Array<{ userId: string; except?: string }>,
|
||||
trustedCleared: [] as string[],
|
||||
policyError: null as Error | null,
|
||||
logins: [] as Array<{ hostId: number; outcome: unknown }>,
|
||||
connectError: null as Error | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
|
||||
});
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
hasCapability: async (
|
||||
_pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => declared.includes(capability) && h.granted.has(capability),
|
||||
hasCachedCapability: (
|
||||
_pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => declared.includes(capability) && h.granted.has(capability),
|
||||
warmPluginGrants: () => {},
|
||||
capabilityRefused: (pluginId: string, capability: string) =>
|
||||
new PluginCapabilityError(pluginId, capability),
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({
|
||||
getActor: () => h.actor,
|
||||
getActorSessionId: () => h.sessionId,
|
||||
}));
|
||||
vi.mock("../../auth/core-auth.js", () => ({
|
||||
assertSecondFactorEnrollmentAllowed: () => {
|
||||
if (h.policyError) throw h.policyError;
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
key: string,
|
||||
factory: () => Promise<unknown>,
|
||||
fn: (client: unknown) => Promise<unknown>,
|
||||
) => {
|
||||
h.pooled.push(key);
|
||||
return fn(await factory());
|
||||
},
|
||||
connectionPool: {
|
||||
clearKeyConnections: (key: string) => h.cleared.push(key),
|
||||
clearAllConnections: () => {
|
||||
h.clearedAll += 1;
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/connect/connect-host.js", () => ({
|
||||
resolveConnectHost: async (target: unknown) =>
|
||||
typeof target === "number"
|
||||
? {
|
||||
id: target,
|
||||
userId: "user-1",
|
||||
ip: `10.0.0.${target}`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
}
|
||||
: target,
|
||||
connectHost: async (target: unknown, options: Record<string, unknown>) => {
|
||||
h.connects.push({ target, options });
|
||||
if (h.connectError) throw h.connectError;
|
||||
const client = Object.assign(new EventEmitter(), { end: vi.fn() });
|
||||
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||
hostStatusService: {
|
||||
reportLogin: (hostId: number, outcome: unknown) =>
|
||||
h.logins.push({ hostId, outcome }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
recordSecondFactor: async (
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
) => {
|
||||
h.factors.push({ userId, pluginId, factorId });
|
||||
},
|
||||
removeSecondFactor: async () => true,
|
||||
}),
|
||||
createCurrentSessionRepository: () => ({
|
||||
revokeAllForUser: async (userId: string, except?: string) => {
|
||||
h.revoked.push({ userId, except });
|
||||
return 0;
|
||||
},
|
||||
}),
|
||||
createCurrentTrustedDeviceRepository: () => ({
|
||||
deleteByUserId: async (userId: string) => {
|
||||
h.trustedCleared.push(userId);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createPluginAuth, createPluginSsh } =
|
||||
await import("../../plugins/ctx-ssh-auth.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { getSshAuthProvider } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const { classifyKeyboardInteractive } =
|
||||
await import("../../hosts/connect/keyboard-interactive.js");
|
||||
const { getLoginMethod, getSecondFactor } =
|
||||
await import("../../auth/registry.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
function manifest(capabilities: string[], auth: Record<string, string[]> = {}) {
|
||||
return {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
contributes: { auth },
|
||||
} as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.granted = new Set();
|
||||
h.connects = [];
|
||||
h.factors = [];
|
||||
h.actor = "user-1";
|
||||
h.cleared = [];
|
||||
h.clearedAll = 0;
|
||||
h.pooled = [];
|
||||
h.logins = [];
|
||||
h.connectError = null;
|
||||
});
|
||||
|
||||
describe("ctx.ssh", () => {
|
||||
it("refuses without ssh:connect and credentials:use, and audits the refusal", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
h.granted = new Set(["ssh:connect"]);
|
||||
await expect(ssh.connect(7)).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"ssh_connect",
|
||||
"host 7",
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
expect(h.connects).toEqual([]);
|
||||
});
|
||||
|
||||
it("connects as the acting user through the pipeline and audits", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
await ssh.connect(7, { purpose: "docker" });
|
||||
expect(h.connects).toEqual([
|
||||
{
|
||||
target: 7,
|
||||
options: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
purpose: "docker",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(audit).toHaveBeenCalledWith("ssh_connect", "host 7", {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a login to a saved host's status, never to a plugin's own host", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await ssh.connect(7);
|
||||
await ssh.connect({ id: 9, ip: "h", port: 22, username: "u" } as never);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.logins).toEqual([{ hostId: 7, outcome: { ok: true } }]);
|
||||
});
|
||||
|
||||
it("reports an auth or host key failure, but not a timeout", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
h.connectError = new Error("Timed out while waiting for handshake");
|
||||
await expect(ssh.connect(7)).rejects.toThrow();
|
||||
h.connectError = new Error("All configured authentication methods failed");
|
||||
await expect(ssh.connect(7)).rejects.toThrow();
|
||||
h.connectError = new Error("Host key changed");
|
||||
await expect(ssh.connect(8)).rejects.toThrow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.logins).toEqual([
|
||||
{ hostId: 7, outcome: { ok: false, hostKeyChanged: false } },
|
||||
{ hostId: 8, outcome: { ok: false, hostKeyChanged: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("never believes a host object's own userId", async () => {
|
||||
h.actor = undefined;
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const forged = {
|
||||
id: 3,
|
||||
ip: "h",
|
||||
port: 22,
|
||||
username: "u",
|
||||
userId: "owner",
|
||||
};
|
||||
await expect(ssh.connect(forged)).rejects.toThrow(/acting user/);
|
||||
await expect(ssh.connect(3)).rejects.toThrow(/acting user/);
|
||||
expect(h.connects).toEqual([]);
|
||||
|
||||
h.actor = "caller";
|
||||
await ssh.connect(forged);
|
||||
expect(h.connects[0].options).toMatchObject({ userId: "caller" });
|
||||
expect(h.connects[0].target).toMatchObject({ userId: "caller" });
|
||||
});
|
||||
|
||||
it("passes a given stream through to the pipeline, gated like any connect", async () => {
|
||||
const stream = { throughSource: true };
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
h.granted = new Set(["ssh:connect"]);
|
||||
await expect(ssh.connect(7, { sock: stream })).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(h.connects).toEqual([]);
|
||||
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
await ssh.connect(7, { purpose: "tunnel", sock: stream });
|
||||
expect(h.connects[0].options).toMatchObject({
|
||||
purpose: "tunnel",
|
||||
sock: stream,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes open connections when the plugin is disposed", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const bag = new DisposableBag("fixture");
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const connection = await ssh.connect(7);
|
||||
await bag.disposeAll();
|
||||
expect(
|
||||
(connection.client as { end: ReturnType<typeof vi.fn> }).end,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh pooled connections", () => {
|
||||
const both = ["ssh:connect", "credentials:use"];
|
||||
|
||||
function plugin(id: string) {
|
||||
const bag = new DisposableBag(id);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: { ...(manifest(both) as object), id } as never,
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
return { bag, ssh };
|
||||
}
|
||||
|
||||
it("disabling one plugin leaves ctx.ssh and the pool working for others", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
const other = plugin("fleets");
|
||||
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => "a");
|
||||
await other.ssh.withConnection(8, { pool: "fleet" }, async () => "b");
|
||||
await metrics.bag.disposeAll();
|
||||
|
||||
expect(h.clearedAll).toBe(0);
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
|
||||
await expect(
|
||||
other.ssh.withConnection(8, { pool: "fleet" }, async () => "still"),
|
||||
).resolves.toBe("still");
|
||||
});
|
||||
|
||||
it("dropPooled clears only this plugin's keys for that host", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
|
||||
await metrics.ssh.withConnection(8, { pool: "stats" }, async () => 1);
|
||||
|
||||
metrics.ssh.dropPooled("stats", 7);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
|
||||
|
||||
// Dropped keys are forgotten, so deactivate only clears what is left.
|
||||
h.cleared = [];
|
||||
await metrics.bag.disposeAll();
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.8:22:root"]);
|
||||
});
|
||||
|
||||
it("dropPooled ignores another pool and an unknown host", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
|
||||
metrics.ssh.dropPooled("other", 7);
|
||||
metrics.ssh.dropPooled("stats", 99);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.cleared).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.auth", () => {
|
||||
it("needs auth:provide and a contributes.auth entry to register anything", () => {
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest([], { sshAuthTypes: ["corp"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
auth.registerSshAuthProvider({
|
||||
type: "corp",
|
||||
labelKey: "corp",
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
}),
|
||||
).toThrow(PluginCapabilityError);
|
||||
|
||||
const declared = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { sshAuthTypes: ["corp"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
declared.registerSshAuthProvider({
|
||||
type: "other",
|
||||
labelKey: "other",
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
}),
|
||||
).toThrow(/contributes\.auth\.sshAuthTypes/);
|
||||
});
|
||||
|
||||
it("registers an SSH auth type, checks the grant on use and removes it on dispose", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { sshAuthTypes: ["corp"] }),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
auth.registerSshAuthProvider({
|
||||
type: "corp",
|
||||
labelKey: "corp",
|
||||
prepare: async (config) => {
|
||||
config.password = "from-corp";
|
||||
return { status: "ready" };
|
||||
},
|
||||
});
|
||||
const provider = getSshAuthProvider("corp")!;
|
||||
expect(provider.pluginId).toBe("fixture");
|
||||
|
||||
await expect(
|
||||
provider.prepare({} as never, {} as never, {} as never),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
const config: Record<string, unknown> = {};
|
||||
await provider.prepare(config as never, {} as never, {} as never);
|
||||
expect(config.password).toBe("from-corp");
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(getSshAuthProvider("corp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never calls a keyboard-interactive handler whose grant is missing", async () => {
|
||||
h.granted = new Set();
|
||||
const bag = new DisposableBag("fixture");
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const detect = vi.fn(() => null);
|
||||
const autoAnswerPasswords = vi.fn(() => true);
|
||||
auth.registerKeyboardInteractiveHandler({
|
||||
id: "gate",
|
||||
label: "Gate",
|
||||
detect,
|
||||
autoAnswerPasswords,
|
||||
});
|
||||
classifyKeyboardInteractive(
|
||||
{
|
||||
name: "Gate sign-in",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "Password:", echo: false }],
|
||||
},
|
||||
{ id: 1, ip: "10.0.0.1", port: 22, username: "root", password: "pw" },
|
||||
);
|
||||
expect(detect).not.toHaveBeenCalled();
|
||||
expect(autoAnswerPasswords).not.toHaveBeenCalled();
|
||||
await bag.disposeAll();
|
||||
});
|
||||
|
||||
it("registers a keyboard-interactive handler that sees only its own host settings", async () => {
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
const bag = new DisposableBag("fixture");
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
auth.registerKeyboardInteractiveHandler({
|
||||
id: "gate",
|
||||
label: "Gate",
|
||||
detect: (round, _host, settings) => {
|
||||
seen.push(settings);
|
||||
return /gate/i.test(round.name)
|
||||
? {
|
||||
kind: "browser",
|
||||
url: "https://gate",
|
||||
code: "AB12",
|
||||
instructions: "",
|
||||
}
|
||||
: null;
|
||||
},
|
||||
autoAnswerPasswords: (_host, settings) => settings.on === true,
|
||||
});
|
||||
|
||||
const target = {
|
||||
id: 1,
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
password: "pw",
|
||||
pluginSettings: { fixture: { on: true }, other: { secret: "x" } },
|
||||
};
|
||||
expect(
|
||||
classifyKeyboardInteractive(
|
||||
{ name: "Gate sign-in", instructions: "", prompts: [] },
|
||||
target,
|
||||
),
|
||||
).toEqual({
|
||||
kind: "browser",
|
||||
id: "gate",
|
||||
label: "Gate",
|
||||
url: "https://gate",
|
||||
code: "AB12",
|
||||
instructions: "",
|
||||
});
|
||||
expect(seen[0]).toEqual({ on: true });
|
||||
expect(
|
||||
classifyKeyboardInteractive(
|
||||
{
|
||||
name: "",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "Password:", echo: false }],
|
||||
},
|
||||
target,
|
||||
),
|
||||
).toEqual({ kind: "auto", responses: ["pw"] });
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(
|
||||
classifyKeyboardInteractive(
|
||||
{ name: "Gate sign-in", instructions: "", prompts: [] },
|
||||
target,
|
||||
).kind,
|
||||
).not.toBe("browser");
|
||||
});
|
||||
|
||||
it("refuses a keyboard-interactive handler that is not declared", () => {
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
auth.registerKeyboardInteractiveHandler({
|
||||
id: "other",
|
||||
label: "Other",
|
||||
detect: () => null,
|
||||
}),
|
||||
).toThrow(/contributes\.auth\.keyboardInteractive/);
|
||||
|
||||
const undeclared = createPluginAuth({
|
||||
manifest: manifest([], { keyboardInteractive: ["gate"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() =>
|
||||
undeclared.registerKeyboardInteractiveHandler({
|
||||
id: "gate",
|
||||
label: "Gate",
|
||||
detect: () => null,
|
||||
}),
|
||||
).toThrow(PluginCapabilityError);
|
||||
});
|
||||
|
||||
it("registers login methods and second factors under the plugin, and records enrolment", async () => {
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
const bag = new DisposableBag("fixture");
|
||||
const audit = vi.fn(async () => {});
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], {
|
||||
loginMethods: ["corp-sso"],
|
||||
secondFactors: ["pin"],
|
||||
}),
|
||||
bag,
|
||||
audit,
|
||||
});
|
||||
auth.registerLoginMethod({
|
||||
id: "corp-sso",
|
||||
labelKey: "corp",
|
||||
kind: "redirect",
|
||||
start: async () => ({ redirectUrl: "https://corp" }),
|
||||
});
|
||||
auth.registerSecondFactor({
|
||||
id: "pin",
|
||||
labelKey: "pin",
|
||||
isEnrolled: async () => true,
|
||||
verify: async () => true,
|
||||
});
|
||||
expect(getLoginMethod("corp-sso")?.pluginId).toBe("fixture");
|
||||
expect(getSecondFactor("fixture", "pin")).toBeDefined();
|
||||
|
||||
await auth.recordEnrollment("user-1", "pin");
|
||||
expect(h.factors).toEqual([
|
||||
{ userId: "user-1", pluginId: "fixture", factorId: "pin" },
|
||||
]);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"auth_factor_enrolled",
|
||||
"pin for user-1",
|
||||
{ success: true },
|
||||
);
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(getLoginMethod("corp-sso")).toBeUndefined();
|
||||
expect(getSecondFactor("fixture", "pin")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("revokes other sessions and trusted devices on enrolment, keeping the caller's session", async () => {
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
h.factors = [];
|
||||
h.revoked = [];
|
||||
h.trustedCleared = [];
|
||||
h.policyError = null;
|
||||
h.actor = "user-1";
|
||||
h.sessionId = "session-a";
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await auth.recordEnrollment("user-1", "pin");
|
||||
expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]);
|
||||
expect(h.trustedCleared).toEqual(["user-1"]);
|
||||
h.actor = undefined;
|
||||
h.sessionId = undefined;
|
||||
});
|
||||
|
||||
it("refuses enrolment when core policy forbids second factors", async () => {
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
h.factors = [];
|
||||
const { LoginMethodError } = await import("@termix/plugin-sdk/backend");
|
||||
h.policyError = new LoginMethodError("password login is off", 409);
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(auth.recordEnrollment("user-1", "pin")).rejects.toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
expect(h.factors).toEqual([]);
|
||||
h.policyError = null;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* The ctx.ssh members an interactive transport (the terminal) needs: host
|
||||
* resolution with secrets, prepare with its provider hooks bound, and the
|
||||
* browser sign-in start and cancel that used to be reached by import.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
actor: "user-1" as string | undefined,
|
||||
resolved: [] as Array<{ by: string; ref: unknown; userId: string }>,
|
||||
built: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
|
||||
});
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
hasCapability: async (
|
||||
_pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => declared.includes(capability) && h.granted.has(capability),
|
||||
hasCachedCapability: (
|
||||
_pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => declared.includes(capability) && h.granted.has(capability),
|
||||
warmPluginGrants: () => {},
|
||||
capabilityRefused: (pluginId: string, capability: string) =>
|
||||
new PluginCapabilityError(pluginId, capability),
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async (_userId: string, hostId: number) => ({
|
||||
hasAccess: hostId !== 7,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/connect/core-providers.js", () => ({
|
||||
ensureCoreSshAuthProviders: () => {},
|
||||
}));
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (id: number, userId: string) => {
|
||||
h.resolved.push({ by: "id", ref: id, userId });
|
||||
return id === 404 ? null : { id, ip: "10.0.0.1", password: "pw" };
|
||||
},
|
||||
resolveHostBySyncId: async (syncId: string, userId: string) => {
|
||||
h.resolved.push({ by: "syncId", ref: syncId, userId });
|
||||
return { id: 9, ip: "10.0.0.9" };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/connect/build-connect-config.js", () => ({
|
||||
buildConnectConfig: async (
|
||||
_host: unknown,
|
||||
options: Record<string, unknown>,
|
||||
) => {
|
||||
h.built.push(options);
|
||||
return {
|
||||
config: { host: "10.0.0.1" },
|
||||
outcome: { status: "ready" },
|
||||
env: { interactive: options.interactive },
|
||||
provider: {
|
||||
type: "fixture-auth",
|
||||
onBanner: (banner: string, _host: unknown, env: unknown) => ({
|
||||
action: "hold",
|
||||
timeoutMs: 1,
|
||||
message: banner,
|
||||
details: { env },
|
||||
}),
|
||||
onAuthFailed: () => ({ status: "retry", patch: {}, message: "again" }),
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostById: async (hostId: number) =>
|
||||
hostId === 1
|
||||
? {
|
||||
id: 1,
|
||||
name: "box",
|
||||
ip: "10.0.0.1",
|
||||
username: "root",
|
||||
authType: "fixture-auth",
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createPluginSsh } = await import("../../plugins/ctx-ssh-auth.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { registerSshAuthProvider, resetSshAuthRegistryForTests } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const { PluginCapabilityError, PluginSshInteractionError } =
|
||||
await import("@termix/plugin-sdk/backend");
|
||||
|
||||
const ALL = ["ssh:connect", "credentials:use", "credentials:read"];
|
||||
|
||||
function sshWith(capabilities = ALL) {
|
||||
const audit = vi.fn(async () => {});
|
||||
const ssh = createPluginSsh({
|
||||
manifest: {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never,
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
return { ssh, audit };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.granted = new Set(ALL);
|
||||
h.actor = "user-1";
|
||||
h.resolved = [];
|
||||
h.built = [];
|
||||
resetSshAuthRegistryForTests();
|
||||
});
|
||||
|
||||
describe("ctx.ssh.resolveHost", () => {
|
||||
it("resolves by sync id first, as the acting user, and audits", async () => {
|
||||
const { ssh, audit } = sshWith();
|
||||
expect(await ssh.resolveHost(3, { syncId: "abc" })).toMatchObject({
|
||||
id: 9,
|
||||
});
|
||||
expect(await ssh.resolveHost(3)).toMatchObject({ id: 3, password: "pw" });
|
||||
expect(h.resolved).toEqual([
|
||||
{ by: "syncId", ref: "abc", userId: "user-1" },
|
||||
{ by: "id", ref: 3, userId: "user-1" },
|
||||
]);
|
||||
expect(audit).toHaveBeenCalledWith("ssh_resolve_host", "host 3", {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the secrets from a plugin without credentials:read", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const { ssh } = sshWith(["ssh:connect", "credentials:use"]);
|
||||
const host = await ssh.resolveHost(3);
|
||||
expect(host).toMatchObject({ id: 3, ip: "10.0.0.1" });
|
||||
expect(host).not.toHaveProperty("password");
|
||||
});
|
||||
|
||||
it("answers null for a host this user cannot reach", async () => {
|
||||
const { ssh } = sshWith();
|
||||
expect(await ssh.resolveHost(404)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses without credentials:use", async () => {
|
||||
h.granted = new Set(["ssh:connect"]);
|
||||
const { ssh } = sshWith(["ssh:connect"]);
|
||||
await expect(ssh.resolveHost(3)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(h.resolved).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh.prepare", () => {
|
||||
it("needs credentials:read, because its config carries the secrets", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const { ssh } = sshWith(["ssh:connect", "credentials:use"]);
|
||||
await expect(
|
||||
ssh.prepare(
|
||||
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
|
||||
{ client: {} },
|
||||
),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
expect(h.built).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes the terminal purpose, interactivity and host key socket through", async () => {
|
||||
const { ssh } = sshWith();
|
||||
const socket = { send: vi.fn() };
|
||||
await ssh.prepare(
|
||||
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
|
||||
{
|
||||
purpose: "terminal",
|
||||
client: {},
|
||||
interactive: true,
|
||||
hostKeySocket: socket,
|
||||
},
|
||||
);
|
||||
expect(h.built[0]).toMatchObject({
|
||||
purpose: "terminal",
|
||||
interactive: true,
|
||||
hostKeySocket: socket,
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("hands back the provider's hooks bound to this host", async () => {
|
||||
const { ssh } = sshWith();
|
||||
const prepared = await ssh.prepare(
|
||||
{ id: 1, ip: "10.0.0.1", port: 22, username: "root" },
|
||||
{ client: {}, interactive: true },
|
||||
);
|
||||
expect(prepared.authType).toBe("fixture-auth");
|
||||
expect(prepared.onBanner?.("waiting")).toMatchObject({
|
||||
action: "hold",
|
||||
message: "waiting",
|
||||
details: { env: { interactive: true } },
|
||||
});
|
||||
expect(
|
||||
prepared.onAuthFailed?.({
|
||||
error: new Error("x"),
|
||||
retries: 0,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
}),
|
||||
).toMatchObject({ status: "retry" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh browser sign-in", () => {
|
||||
it("starts the interaction on the host's own provider", async () => {
|
||||
const started = vi.fn(async () => {});
|
||||
registerSshAuthProvider({
|
||||
type: "fixture-auth",
|
||||
pluginId: "core",
|
||||
labelKey: "k",
|
||||
interaction: "fixture",
|
||||
startInteraction: started,
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
const { ssh } = sshWith();
|
||||
await ssh.startInteraction("fixture", {
|
||||
hostId: 1,
|
||||
socket: "ws",
|
||||
requestOrigin: "https://termix.example",
|
||||
});
|
||||
expect(started).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
socket: "ws",
|
||||
requestOrigin: "https://termix.example",
|
||||
host: { name: "box", ip: "10.0.0.1", username: "root" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("will not start one for a host the actor cannot reach", async () => {
|
||||
const started = vi.fn(async () => {});
|
||||
registerSshAuthProvider({
|
||||
type: "fixture-auth",
|
||||
pluginId: "core",
|
||||
labelKey: "k",
|
||||
interaction: "fixture",
|
||||
startInteraction: started,
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
const { ssh } = sshWith();
|
||||
await expect(
|
||||
ssh.startInteraction("fixture", {
|
||||
hostId: 7,
|
||||
socket: null,
|
||||
requestOrigin: "",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PluginSshInteractionError);
|
||||
expect(started).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says why when nothing can start it", async () => {
|
||||
const { ssh } = sshWith();
|
||||
await expect(
|
||||
ssh.startInteraction("fixture", {
|
||||
hostId: 1,
|
||||
socket: null,
|
||||
requestOrigin: "",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PluginSshInteractionError);
|
||||
await expect(
|
||||
ssh.startInteraction("fixture", {
|
||||
hostId: 2,
|
||||
socket: null,
|
||||
requestOrigin: "",
|
||||
}),
|
||||
).rejects.toThrow("Host not found");
|
||||
});
|
||||
|
||||
it("cancels on every provider that owns the interaction", async () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const other = vi.fn();
|
||||
for (const [type, interaction, cancel] of [
|
||||
["a", "fixture", first],
|
||||
["b", "fixture", second],
|
||||
["c", "elsewhere", other],
|
||||
] as const) {
|
||||
registerSshAuthProvider({
|
||||
type,
|
||||
pluginId: "core",
|
||||
labelKey: "k",
|
||||
interaction,
|
||||
cancelInteraction: cancel,
|
||||
prepare: async () => ({ status: "ready" }),
|
||||
});
|
||||
}
|
||||
const { ssh } = sshWith();
|
||||
await ssh.cancelInteraction("fixture", { requestId: "r1" });
|
||||
expect(first).toHaveBeenCalledWith({ userId: "user-1", requestId: "r1" });
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
writes: [] as Array<[string, string]>,
|
||||
reloads: 0,
|
||||
renewers: [] as Array<{ pluginId: string; pluginName: string }>,
|
||||
challenges: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../tls/tls-service.js", () => ({
|
||||
getTlsStatus: async () => ({
|
||||
enabled: true,
|
||||
certificate: null,
|
||||
renewal: h.renewers[0] ?? null,
|
||||
}),
|
||||
writeTlsCertificate: async (cert: string, key: string) => {
|
||||
if (key === "wrong")
|
||||
throw new Error("The certificate and private key do not match");
|
||||
h.writes.push([cert, key]);
|
||||
return { subject: "CN=x" };
|
||||
},
|
||||
reloadTls: async () => {
|
||||
h.reloads++;
|
||||
return { applied: true, message: "ok" };
|
||||
},
|
||||
registerTlsRenewer: (renewer: { pluginId: string; pluginName: string }) => {
|
||||
h.renewers.push(renewer);
|
||||
return () => {
|
||||
h.renewers = h.renewers.filter((entry) => entry !== renewer);
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../tls/acme-challenges.js", () => ({
|
||||
publishChallenge: (token: string, content: string) => {
|
||||
h.challenges.set(token, content);
|
||||
return () => h.challenges.delete(token);
|
||||
},
|
||||
}));
|
||||
|
||||
const { createPluginSystem } = await import("../../plugins/ctx-system.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
let audits: Array<{ action: string; success: boolean }>;
|
||||
|
||||
function create(capabilities = ["system:tls"]) {
|
||||
const bag = new DisposableBag("acme-ssl");
|
||||
const system = createPluginSystem({
|
||||
manifest: {
|
||||
id: "acme-ssl",
|
||||
name: "ACME Certificates",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never,
|
||||
bag,
|
||||
audit: async (action, _details, outcome) => {
|
||||
audits.push({ action, success: outcome.success });
|
||||
},
|
||||
});
|
||||
return { system, bag };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.granted = new Set(["system:tls"]);
|
||||
h.writes = [];
|
||||
h.reloads = 0;
|
||||
h.renewers = [];
|
||||
h.challenges = new Map();
|
||||
audits = [];
|
||||
});
|
||||
|
||||
describe("ctx.system", () => {
|
||||
it("writes, reloads and audits every call", async () => {
|
||||
const { system } = create();
|
||||
await system.writeTlsCertificate("CERT", "KEY");
|
||||
await system.reloadTls();
|
||||
expect(h.writes).toEqual([["CERT", "KEY"]]);
|
||||
expect(h.reloads).toBe(1);
|
||||
expect(audits).toEqual([
|
||||
{ action: "tls_write", success: true },
|
||||
{ action: "tls_reload", success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes a rejected pair back to the plugin and audits the failure", async () => {
|
||||
const { system } = create();
|
||||
await expect(system.writeTlsCertificate("CERT", "wrong")).rejects.toThrow(
|
||||
/do not match/,
|
||||
);
|
||||
expect(audits).toEqual([{ action: "tls_write", success: false }]);
|
||||
});
|
||||
|
||||
it("refuses without system:tls, declared or granted", async () => {
|
||||
await expect(create([]).system.reloadTls()).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
h.granted.clear();
|
||||
await expect(create().system.tlsStatus()).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(h.reloads).toBe(0);
|
||||
expect(audits.every((entry) => !entry.success)).toBe(true);
|
||||
});
|
||||
|
||||
it("drops the renewer and challenges when the plugin is disabled", async () => {
|
||||
const { system, bag } = create();
|
||||
await system.registerTlsRenewer();
|
||||
await system.publishHttpChallenge("tok", "tok.key");
|
||||
expect((await system.tlsStatus()).renewal?.pluginId).toBe("acme-ssl");
|
||||
expect(h.challenges.get("tok")).toBe("tok.key");
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(h.renewers).toEqual([]);
|
||||
expect(h.challenges.size).toBe(0);
|
||||
});
|
||||
|
||||
it("lets the plugin withdraw a challenge itself, once", async () => {
|
||||
const { system, bag } = create();
|
||||
const remove = await system.publishHttpChallenge("tok", "tok.key");
|
||||
remove();
|
||||
remove();
|
||||
expect(h.challenges.size).toBe(0);
|
||||
expect(bag.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Disabling a plugin releases what it owns.
|
||||
*
|
||||
* A plugin that keeps a port after deactivate makes "disabled" a lie and
|
||||
* makes re-enabling fail on a port that is still held, which is exactly the
|
||||
* bug the docker console had: its WebSocket server was created at module
|
||||
* scope, so it survived deactivate and the second activate got nothing.
|
||||
*
|
||||
* These use a real listening socket rather than a mock, because the thing
|
||||
* under test is whether the OS still has the port.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import net from "node:net";
|
||||
import { PluginLoader } from "../../plugins/loader.js";
|
||||
import { clearRegistry } from "../../plugins/registry.js";
|
||||
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
assertCapability: vi.fn().mockResolvedValue(undefined),
|
||||
hasCapability: vi.fn().mockResolvedValue(true),
|
||||
PluginCapabilityError: class extends Error {},
|
||||
invalidatePluginPermissionCache: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_PORT = 39102;
|
||||
|
||||
function isListening(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect({ port, host: "127.0.0.1" });
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin that owns a real port, the way ssh-terminal and docker do.
|
||||
*
|
||||
* The server is created inside activate and closed through a disposable, not
|
||||
* at module scope, which is the rule the runtime documents.
|
||||
*/
|
||||
function portOwningSource(port: number): string {
|
||||
return `
|
||||
import net from "node:net";
|
||||
|
||||
export async function activate(ctx) {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(${port}, "127.0.0.1", resolve));
|
||||
ctx.disposables.add(
|
||||
() => new Promise((resolve) => server.close(() => resolve())),
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
let fixture: Fixture | null = null;
|
||||
let loader: PluginLoader | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await loader?.shutdown();
|
||||
loader = null;
|
||||
fixture?.cleanup();
|
||||
fixture = null;
|
||||
clearRegistry();
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
describe("disabling a plugin releases its resources", () => {
|
||||
async function start(): Promise<PluginLoader> {
|
||||
fixture = createFixturePlugin({
|
||||
backendSource: portOwningSource(TEST_PORT),
|
||||
});
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = fixture.root;
|
||||
process.env.DATA_DIR = fixture.root;
|
||||
|
||||
const instance = new PluginLoader();
|
||||
await instance.loadAll();
|
||||
await instance.activate(fixture.id);
|
||||
loader = instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
it("frees the port the plugin was listening on", async () => {
|
||||
const instance = await start();
|
||||
expect(await isListening(TEST_PORT)).toBe(true);
|
||||
|
||||
await instance.deactivate(fixture!.id);
|
||||
|
||||
expect(await isListening(TEST_PORT)).toBe(false);
|
||||
});
|
||||
|
||||
// The regression the docker console hit: the module stays in the ESM cache,
|
||||
// so the second activate runs against it and has to be able to bind again.
|
||||
it("can take the port again after being re-enabled", async () => {
|
||||
const instance = await start();
|
||||
|
||||
await instance.deactivate(fixture!.id);
|
||||
expect(await isListening(TEST_PORT)).toBe(false);
|
||||
|
||||
await instance.activate(fixture!.id);
|
||||
|
||||
expect(await isListening(TEST_PORT)).toBe(true);
|
||||
expect(instance.get(fixture!.id)?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("frees the port on shutdown too", async () => {
|
||||
const instance = await start();
|
||||
|
||||
await instance.shutdown();
|
||||
loader = null;
|
||||
|
||||
expect(await isListening(TEST_PORT)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||
|
||||
describe("plugin event bus", () => {
|
||||
afterEach(() => {
|
||||
pluginEvents.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("delivers a payload to every subscriber of a topic", () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
pluginEvents.on("topic.a", first);
|
||||
pluginEvents.on("topic.a", second);
|
||||
pluginEvents.emit("topic.a", { value: 1 });
|
||||
|
||||
expect(first).toHaveBeenCalledWith({ value: 1 });
|
||||
expect(second).toHaveBeenCalledWith({ value: 1 });
|
||||
});
|
||||
|
||||
it("does not deliver across topics", () => {
|
||||
const listener = vi.fn();
|
||||
pluginEvents.on("topic.a", listener);
|
||||
pluginEvents.emit("topic.b", {});
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emitting a topic with no subscribers is a no-op", () => {
|
||||
expect(() => pluginEvents.emit("nobody.listening", {})).not.toThrow();
|
||||
});
|
||||
|
||||
it("unsubscribes and cleans up the empty topic", () => {
|
||||
const listener = vi.fn();
|
||||
const off = pluginEvents.on("topic.a", listener);
|
||||
|
||||
expect(pluginEvents.listenerCount("topic.a")).toBe(1);
|
||||
off();
|
||||
expect(pluginEvents.listenerCount("topic.a")).toBe(0);
|
||||
|
||||
pluginEvents.emit("topic.a", {});
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a throwing subscriber does not stop the others or the caller", async () => {
|
||||
const after = vi.fn();
|
||||
pluginEvents.on("topic.a", () => {
|
||||
throw new Error("subscriber exploded");
|
||||
});
|
||||
pluginEvents.on("topic.a", after);
|
||||
|
||||
expect(() => pluginEvents.emit("topic.a", {})).not.toThrow();
|
||||
expect(after).toHaveBeenCalled();
|
||||
|
||||
const { pluginLogger } = await import("../../utils/logger.js");
|
||||
expect(pluginLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a rejecting async subscriber is reported, not left unhandled", async () => {
|
||||
pluginEvents.on("topic.a", async () => {
|
||||
throw new Error("async subscriber exploded");
|
||||
});
|
||||
|
||||
expect(() => pluginEvents.emit("topic.a", {})).not.toThrow();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const { pluginLogger } = await import("../../utils/logger.js");
|
||||
expect(pluginLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a subscriber added during an emit does not receive that same emit", () => {
|
||||
const late = vi.fn();
|
||||
pluginEvents.on("topic.a", () => {
|
||||
pluginEvents.on("topic.a", late);
|
||||
});
|
||||
|
||||
pluginEvents.emit("topic.a", {});
|
||||
expect(late).not.toHaveBeenCalled();
|
||||
|
||||
pluginEvents.emit("topic.a", {});
|
||||
expect(late).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("exposes the internal topic names the server publishes", () => {
|
||||
expect(TOPICS.hostSessionStatus).toBe("host.session.status");
|
||||
expect(TOPICS.internalEvent).toBe("internal.event");
|
||||
expect(TOPICS.hostStatus).toBe("host.status");
|
||||
expect(TOPICS.hostKeyUpdated).toBe("host.key.updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hosts publishers route through the bus", () => {
|
||||
// No vi.resetModules() here: the bus is a module singleton, and resetting
|
||||
// would hand these imports a different instance than the one subscribed to.
|
||||
afterEach(() => {
|
||||
pluginEvents.clear();
|
||||
});
|
||||
|
||||
it("emitInternalEvent emits internal.event", async () => {
|
||||
const { emitInternalEvent } =
|
||||
await import("../../hosts/internal-events.js");
|
||||
const listener = vi.fn();
|
||||
pluginEvents.on(TOPICS.internalEvent, listener);
|
||||
|
||||
emitInternalEvent("host_deleted", "user-1", 42, { a: 1 });
|
||||
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
event: "host_deleted",
|
||||
userId: "user-1",
|
||||
hostId: 42,
|
||||
details: { a: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("emitInternalEvent ignores a missing userId", async () => {
|
||||
const { emitInternalEvent } =
|
||||
await import("../../hosts/internal-events.js");
|
||||
const listener = vi.fn();
|
||||
pluginEvents.on(TOPICS.internalEvent, listener);
|
||||
|
||||
emitInternalEvent("host_deleted", "");
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hostSessionStatus publishes host.session.status on the refcount edges", async () => {
|
||||
const { HostSessionStatus } =
|
||||
await import("../../hosts/host-session-status.js");
|
||||
const seen: unknown[] = [];
|
||||
pluginEvents.on(TOPICS.hostSessionStatus, (p) => seen.push(p));
|
||||
|
||||
const status = new HostSessionStatus();
|
||||
const releaseFirst = status.register(7);
|
||||
const releaseSecond = status.register(7);
|
||||
|
||||
// Only the 0 -> 1 edge publishes.
|
||||
expect(seen).toEqual([{ hostId: 7, online: true }]);
|
||||
|
||||
releaseFirst();
|
||||
expect(seen).toHaveLength(1);
|
||||
|
||||
// ... and only the 1 -> 0 edge.
|
||||
releaseSecond();
|
||||
expect(seen).toEqual([
|
||||
{ hostId: 7, online: true },
|
||||
{ hostId: 7, online: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Builds a real unpacked plugin on disk for the runtime tests.
|
||||
*
|
||||
* The backend entry has to be a genuine .mjs file the loader can import, and
|
||||
* tsc only emits .ts, so these are written at test time rather than committed.
|
||||
* Same mkdtemp convention the other disk-touching backend tests use.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SUPPORTED_PLUGIN_API_VERSION } from "../../plugins/manifest.js";
|
||||
|
||||
export interface FixtureOptions {
|
||||
id?: string;
|
||||
/** Catalog capability ids the manifest declares. */
|
||||
capabilities?: string[];
|
||||
/** Body of backend/index.mjs. Must export activate(ctx). */
|
||||
backendSource?: string;
|
||||
/** Omit the backend entry file even though the manifest declares one. */
|
||||
omitBackendEntry?: boolean;
|
||||
manifestOverrides?: Record<string, unknown>;
|
||||
/** Write into this root instead of a fresh temp dir. */
|
||||
root?: string;
|
||||
}
|
||||
|
||||
export interface Fixture {
|
||||
root: string;
|
||||
dir: string;
|
||||
id: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_BACKEND = `
|
||||
export async function activate(ctx) {
|
||||
ctx.log.info("fixture activated");
|
||||
}
|
||||
`;
|
||||
|
||||
export function createFixturePlugin(options: FixtureOptions = {}): Fixture {
|
||||
const id = options.id ?? "sample-plugin";
|
||||
const root =
|
||||
options.root ?? fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugins-"));
|
||||
const dir = path.join(root, id);
|
||||
|
||||
fs.mkdirSync(path.join(dir, "backend"), { recursive: true });
|
||||
|
||||
const manifest = {
|
||||
id,
|
||||
name: "Sample Plugin",
|
||||
version: "1.0.0",
|
||||
description: "Fixture plugin for runtime tests.",
|
||||
author: { name: "Termix Tests" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
|
||||
capabilities: options.capabilities ?? ["hosts:read", "kv:own"],
|
||||
backend: "backend/index.mjs",
|
||||
...options.manifestOverrides,
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "manifest.json"),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
);
|
||||
|
||||
if (!options.omitBackendEntry) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "backend", "index.mjs"),
|
||||
options.backendSource ?? DEFAULT_BACKEND,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
dir,
|
||||
id,
|
||||
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parsePlatformProbe,
|
||||
buildPackageActionCommand,
|
||||
buildPackageRemoveCommand,
|
||||
isValidPackageName,
|
||||
buildSudoCommand,
|
||||
parseUpgradable,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
|
||||
describe("parsePlatformProbe", () => {
|
||||
it("prefers dnf over yum when both are present", () => {
|
||||
const out = ["dnf=1", "yum=1", "apt=0", "pacman=0"].join("\n");
|
||||
expect(parsePlatformProbe(out).pkg).toBe("dnf");
|
||||
});
|
||||
|
||||
it("reads the pretty OS name and tool flags", () => {
|
||||
const out = [
|
||||
"systemd=1",
|
||||
"apt=1",
|
||||
"dnf=0",
|
||||
"yum=0",
|
||||
"pacman=0",
|
||||
"certbot=1",
|
||||
"acmesh=0",
|
||||
"docker=1",
|
||||
"os=Debian GNU/Linux 12 (bookworm)",
|
||||
].join("\n");
|
||||
const info = parsePlatformProbe(out);
|
||||
expect(info).toMatchObject({
|
||||
hasSystemd: true,
|
||||
pkg: "apt",
|
||||
hasCertbot: true,
|
||||
hasAcmeSh: false,
|
||||
hasDocker: true,
|
||||
osPrettyName: "Debian GNU/Linux 12 (bookworm)",
|
||||
});
|
||||
});
|
||||
|
||||
it("nulls the OS name when the probe line is empty", () => {
|
||||
expect(parsePlatformProbe("os=").osPrettyName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPackageActionCommand", () => {
|
||||
it("builds install and upgrade-all commands per package manager", () => {
|
||||
expect(buildPackageActionCommand("apt", "install", "curl")).toContain(
|
||||
"apt-get -y install curl",
|
||||
);
|
||||
expect(buildPackageActionCommand("dnf", "upgrade-all")).toBe(
|
||||
"dnf -y upgrade",
|
||||
);
|
||||
expect(buildPackageActionCommand("yum", "install", "curl")).toBe(
|
||||
"yum -y install curl",
|
||||
);
|
||||
expect(buildPackageActionCommand("pacman", "upgrade-all")).toBe(
|
||||
"pacman -Syu --noconfirm",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no package manager was detected", () => {
|
||||
expect(buildPackageActionCommand(null, "install", "curl")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPackageRemoveCommand", () => {
|
||||
it("builds the correct remove command per package manager", () => {
|
||||
expect(buildPackageRemoveCommand("apt", "curl")).toContain(
|
||||
"apt-get -y remove curl",
|
||||
);
|
||||
expect(buildPackageRemoveCommand("dnf", "curl")).toBe("dnf -y remove curl");
|
||||
expect(buildPackageRemoveCommand("yum", "curl")).toBe("yum -y remove curl");
|
||||
expect(buildPackageRemoveCommand("pacman", "curl")).toBe(
|
||||
"pacman -R --noconfirm curl",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no package manager was detected", () => {
|
||||
expect(buildPackageRemoveCommand(null, "curl")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidPackageName", () => {
|
||||
it("accepts ordinary package names", () => {
|
||||
expect(isValidPackageName("curl")).toBe(true);
|
||||
expect(isValidPackageName("lib32-glibc")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects shell metacharacters and empty strings", () => {
|
||||
expect(isValidPackageName("curl; rm -rf /")).toBe(false);
|
||||
expect(isValidPackageName("")).toBe(false);
|
||||
expect(isValidPackageName(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSudoCommand", () => {
|
||||
it("single-quotes the password and wraps the marker echo around the command", () => {
|
||||
const cmd = buildSudoCommand("uptime", "p'w");
|
||||
expect(cmd).toContain("sudo -S -p ''");
|
||||
expect(cmd).toContain("__TX_SUDO_OK__");
|
||||
expect(cmd).toContain("uptime");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUpgradable", () => {
|
||||
it("parses apt list --upgradable output", () => {
|
||||
const out = "curl/stable 7.88.0 amd64 [upgradable from: 7.87.0]";
|
||||
expect(parseUpgradable("apt", out)).toEqual([
|
||||
{ name: "curl", newVersion: "7.88.0", currentVersion: "7.87.0" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list for no package manager", () => {
|
||||
expect(parseUpgradable(null, "anything")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* ctx.http: the one way a plugin serves HTTP.
|
||||
*
|
||||
* Everything here is about the middleware core puts in front of a plugin's
|
||||
* routes, because that is what moving off the dispatcher shims bought: auth by
|
||||
* default, a truthful 503 while disabled, a capability that can be revoked
|
||||
* without a restart, and streaming that still streams.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
validToken: "valid-token",
|
||||
userId: "user-1",
|
||||
granted: new Set<string>(["network:serve"]),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
const header = req.headers.authorization;
|
||||
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
|
||||
if (token !== state.validToken) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
(req as express.Request & { userId?: string }).userId = state.userId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
hasCapability: async (
|
||||
_pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => declared.includes(capability) && state.granted.has(capability),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: vi.fn() }));
|
||||
|
||||
const http = await import("../../plugins/http.js");
|
||||
const { mountPluginApi } =
|
||||
await import("../../database/routes/plugin-api-routes.js");
|
||||
|
||||
function manifest(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "sample-plugin",
|
||||
name: "Sample Plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: ["network:serve"],
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
let server: Server | null = null;
|
||||
|
||||
async function startServer(): Promise<string> {
|
||||
const app = express();
|
||||
// The same mount database.ts uses. It used to put core auth in front of the
|
||||
// whole prefix, which made every public plugin route unreachable.
|
||||
mountPluginApi(app);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server = app.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server!.address() as AddressInfo;
|
||||
resolve(`http://127.0.0.1:${port}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const authed = { Authorization: `Bearer ${state.validToken}` };
|
||||
|
||||
beforeEach(() => {
|
||||
http.resetPluginHttp();
|
||||
state.granted = new Set(["network:serve"]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await new Promise((resolve) => server!.close(() => resolve(undefined)));
|
||||
server = null;
|
||||
}
|
||||
http.resetPluginHttp();
|
||||
});
|
||||
|
||||
describe("ctx.http mounting", () => {
|
||||
it("serves a plugin's routes once it registers a router", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("404s a plugin id that never registered a router", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
const response = await fetch(`${base}/plugin-api/nobody/thing`, {
|
||||
headers: authed,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("unmounts on deactivate, so a disabled plugin stops serving", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
expect(
|
||||
(
|
||||
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
http.unregisterPluginHttp("sample-plugin");
|
||||
|
||||
expect(
|
||||
(
|
||||
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.http authentication", () => {
|
||||
it("requires authentication by default", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
|
||||
expect((await fetch(`${base}/plugin-api/sample-plugin/thing`)).status).toBe(
|
||||
401,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: { Authorization: "Bearer nope" },
|
||||
})
|
||||
).status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it("serves a declared public path without a token", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
options: { public: ["/webhook/:token"] },
|
||||
reportError: () => {},
|
||||
});
|
||||
router.post("/webhook/:token", (req, res) =>
|
||||
res.json({ token: req.params.token }),
|
||||
);
|
||||
router.get("/private", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
|
||||
const open = await fetch(
|
||||
`${base}/plugin-api/sample-plugin/webhook/abc123`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
expect(open.status).toBe(200);
|
||||
expect(await open.json()).toEqual({ token: "abc123" });
|
||||
|
||||
// Declaring one route public must not open the rest of the plugin.
|
||||
expect(
|
||||
(await fetch(`${base}/plugin-api/sample-plugin/private`)).status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it("does not let a public parameter swallow extra path segments", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
options: { public: ["/webhook/:token"] },
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/webhook/:token/secret", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
|
||||
const response = await fetch(
|
||||
`${base}/plugin-api/sample-plugin/webhook/abc/secret`,
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("opens everything below a trailing /* and nothing beside it", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
options: { public: ["/chooser/:id/*"] },
|
||||
reportError: () => {},
|
||||
});
|
||||
router.use("/chooser", (_req, res) => res.json({ ok: true }));
|
||||
router.get("/chooserx/a", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
const root = `${base}/plugin-api/sample-plugin`;
|
||||
|
||||
expect((await fetch(`${root}/chooser/abc`)).status).toBe(200);
|
||||
expect((await fetch(`${root}/chooser/abc/select/x`)).status).toBe(200);
|
||||
expect((await fetch(`${root}/chooser`)).status).toBe(401);
|
||||
expect((await fetch(`${root}/chooserx/a`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it("gives the plugin the user core authenticated, not one the caller named", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/who", (req, res) =>
|
||||
res.json({
|
||||
userId: (req as express.Request & { userId?: string }).userId,
|
||||
}),
|
||||
);
|
||||
|
||||
const base = await startServer();
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/who`, {
|
||||
headers: { ...authed, "x-user-id": "attacker" },
|
||||
});
|
||||
|
||||
expect(await response.json()).toEqual({ userId: state.userId });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.http capability", () => {
|
||||
it("refuses to build a router without network:serve declared", () => {
|
||||
expect(() =>
|
||||
http.createPluginRouter({
|
||||
manifest: manifest({ capabilities: [] }),
|
||||
reportError: () => {},
|
||||
}),
|
||||
).not.toThrow();
|
||||
// Declaration is checked in ctx.ts; the grant is checked per request below.
|
||||
});
|
||||
|
||||
it("403s every request when the grant is revoked", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
expect(
|
||||
(
|
||||
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
// Revoked at runtime: no restart, and the next request is refused.
|
||||
state.granted.delete("network:serve");
|
||||
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.http while the plugin is disabled", () => {
|
||||
it("answers 503 rather than 404", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// "Installed but not running" is a different fact from "no such plugin",
|
||||
// and a caller can act on the difference.
|
||||
http.setPluginEnabledCheck(() => false);
|
||||
|
||||
const base = await startServer();
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.http errors", () => {
|
||||
it("reports a throwing route without leaking a stack", async () => {
|
||||
const reported: unknown[] = [];
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: (error) => reported.push(error),
|
||||
});
|
||||
router.get("/boom", () => {
|
||||
throw new Error("secret internal detail");
|
||||
});
|
||||
|
||||
const base = await startServer();
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/boom`, {
|
||||
headers: authed,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.text();
|
||||
expect(body).not.toContain("secret internal detail");
|
||||
// Counted against the plugin's error budget.
|
||||
expect(reported).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.http streaming", () => {
|
||||
it("streams an SSE response as it is produced", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
|
||||
let release: (() => void) | null = null;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
router.get("/stream", async (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.write("event: progress\ndata: 1\n\n");
|
||||
// The first chunk has to arrive before the handler finishes, or this is
|
||||
// buffering rather than streaming.
|
||||
await gate;
|
||||
res.write("event: result\ndata: done\n\n");
|
||||
res.end();
|
||||
});
|
||||
|
||||
const base = await startServer();
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/stream`, {
|
||||
headers: authed,
|
||||
});
|
||||
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const first = await reader.read();
|
||||
expect(decoder.decode(first.value)).toContain("data: 1");
|
||||
|
||||
release!();
|
||||
|
||||
let rest = "";
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
rest += decoder.decode(chunk.value);
|
||||
}
|
||||
expect(rest).toContain("data: done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("public route rate limit", () => {
|
||||
afterEach(() => {
|
||||
http.resetPublicRateLimits();
|
||||
delete process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT;
|
||||
});
|
||||
|
||||
it("allows up to the limit per address and plugin, then refuses", () => {
|
||||
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "3";
|
||||
const now = 1_000;
|
||||
const hits = [1, 2, 3, 4].map(() =>
|
||||
http.overPublicRateLimit("demo", "1.2.3.4", now),
|
||||
);
|
||||
expect(hits).toEqual([false, false, false, true]);
|
||||
expect(http.overPublicRateLimit("demo", "5.6.7.8", now)).toBe(false);
|
||||
expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a new window after a minute", () => {
|
||||
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
|
||||
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
|
||||
expect(http.overPublicRateLimit("demo", "ip", 10)).toBe(true);
|
||||
expect(http.overPublicRateLimit("demo", "ip", 60_001)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* The single-tier loader: discovery, dependency order and lifecycle.
|
||||
*
|
||||
* These drive the real PluginLoader against real plugin directories written
|
||||
* to a temp dir, because the things worth testing here are exactly the ones a
|
||||
* mock would paper over: does a manifest on disk parse, does an id collision
|
||||
* get refused, does a plugin's own deactivate actually run.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PluginLoader } from "../../plugins/loader.js";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginStorageRepository: () => ({
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
delete: async () => false,
|
||||
listKeys: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Points the loader at `bundled` and `user` as its two discovery roots. */
|
||||
function setRoots(bundled: string, user: string): void {
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
||||
process.env.DATA_DIR = user;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.DATA_DIR;
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
describe("PluginLoader discovery", () => {
|
||||
it("loads a valid plugin directory", async () => {
|
||||
const fixture = createFixturePlugin();
|
||||
cleanups.push(fixture.cleanup);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
const plugin = await loader.load(fixture.dir, "bundled");
|
||||
|
||||
expect(plugin.id).toBe("sample-plugin");
|
||||
expect(plugin.state).toBe("loaded");
|
||||
expect(plugin.manifest.capabilities).toEqual(["hosts:read", "kv:own"]);
|
||||
});
|
||||
|
||||
it("rejects a directory whose name does not match the manifest id", async () => {
|
||||
const fixture = createFixturePlugin({
|
||||
manifestOverrides: { id: "something-else" },
|
||||
});
|
||||
cleanups.push(fixture.cleanup);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
await expect(loader.load(fixture.dir, "bundled")).rejects.toThrow(
|
||||
/does not match manifest id/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a manifest whose backend entry is missing", async () => {
|
||||
const fixture = createFixturePlugin({ omitBackendEntry: true });
|
||||
cleanups.push(fixture.cleanup);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
await expect(loader.load(fixture.dir, "bundled")).rejects.toThrow(
|
||||
/backend entry .* is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips an invalid directory and loads the rest", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
|
||||
createFixturePlugin({ id: "good-plugin", root: bundled });
|
||||
const broken = path.join(bundled, "broken-plugin");
|
||||
fs.mkdirSync(broken, { recursive: true });
|
||||
fs.writeFileSync(path.join(broken, "manifest.json"), "{ not json");
|
||||
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
const loaded = await loader.loadAll();
|
||||
|
||||
expect(loaded.map((plugin) => plugin.id)).toEqual(["good-plugin"]);
|
||||
});
|
||||
|
||||
// A plugin dropped into the data directory must never be able to take over
|
||||
// an id the install already ships, or "ssh-terminal" becomes whatever the
|
||||
// last person to write that folder wanted it to be.
|
||||
it("refuses a user plugin that shadows a bundled id", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
const userPlugins = path.join(user, "plugins");
|
||||
fs.mkdirSync(userPlugins, { recursive: true });
|
||||
|
||||
createFixturePlugin({ id: "ssh-terminal", root: bundled });
|
||||
createFixturePlugin({
|
||||
id: "ssh-terminal",
|
||||
root: userPlugins,
|
||||
manifestOverrides: { name: "Impostor" },
|
||||
});
|
||||
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
const loaded = await loader.loadAll();
|
||||
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].source).toBe("bundled");
|
||||
expect(loaded[0].manifest.name).toBe("Sample Plugin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginLoader dependency resolution", () => {
|
||||
async function loadAll(
|
||||
specs: Array<{ id: string; manifestOverrides?: Record<string, unknown> }>,
|
||||
): Promise<PluginLoader> {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
for (const spec of specs) {
|
||||
createFixturePlugin({ ...spec, root: bundled });
|
||||
}
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
return loader;
|
||||
}
|
||||
|
||||
it("orders a dependency before its dependent", async () => {
|
||||
const loader = await loadAll([
|
||||
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
|
||||
{ id: "beta" },
|
||||
]);
|
||||
|
||||
const { order, blocked, cycles } = loader.resolveOrder(["alpha", "beta"]);
|
||||
|
||||
expect(cycles.size).toBe(0);
|
||||
expect(blocked.size).toBe(0);
|
||||
expect(order.indexOf("beta")).toBeLessThan(order.indexOf("alpha"));
|
||||
});
|
||||
|
||||
it("blocks a plugin whose hard dependency is not installed", async () => {
|
||||
const loader = await loadAll([
|
||||
{
|
||||
id: "alpha",
|
||||
manifestOverrides: { dependencies: { missing: "^1.0.0" } },
|
||||
},
|
||||
]);
|
||||
|
||||
const { order, blocked } = loader.resolveOrder(["alpha"]);
|
||||
|
||||
expect(order).toEqual([]);
|
||||
expect(blocked.get("alpha")).toMatch(/not installed/);
|
||||
});
|
||||
|
||||
it("blocks a plugin whose dependency is present at the wrong version", async () => {
|
||||
const loader = await loadAll([
|
||||
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^2.0.0" } } },
|
||||
{ id: "beta" },
|
||||
]);
|
||||
|
||||
const { blocked } = loader.resolveOrder(["alpha", "beta"]);
|
||||
|
||||
expect(blocked.get("alpha")).toMatch(/1\.0\.0 is installed/);
|
||||
});
|
||||
|
||||
it("blocks a plugin whose dependency is installed but disabled", async () => {
|
||||
const loader = await loadAll([
|
||||
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
|
||||
{ id: "beta" },
|
||||
]);
|
||||
|
||||
const { blocked } = loader.resolveOrder(["alpha"]);
|
||||
|
||||
expect(blocked.get("alpha")).toMatch(/not enabled/);
|
||||
});
|
||||
|
||||
// Nothing to wait for, so this is a failure rather than a block.
|
||||
it("fails every plugin in a dependency cycle", async () => {
|
||||
const loader = await loadAll([
|
||||
{ id: "alpha", manifestOverrides: { dependencies: { beta: "^1.0.0" } } },
|
||||
{ id: "beta", manifestOverrides: { dependencies: { alpha: "^1.0.0" } } },
|
||||
]);
|
||||
|
||||
const { order, cycles } = loader.resolveOrder(["alpha", "beta"]);
|
||||
|
||||
expect(order).toEqual([]);
|
||||
expect(cycles.get("alpha")).toMatch(/cycle/);
|
||||
expect(cycles.get("beta")).toMatch(/cycle/);
|
||||
});
|
||||
|
||||
it("activates in dependency order and reports blocked plugins", async () => {
|
||||
const marker = path.join(tempRoot("termix-order-"), "order.txt");
|
||||
const record = (id: string) => `
|
||||
import fs from "node:fs";
|
||||
export async function activate() {
|
||||
fs.appendFileSync(${JSON.stringify(marker)}, "${id}\\n");
|
||||
}
|
||||
`;
|
||||
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({
|
||||
id: "alpha",
|
||||
root: bundled,
|
||||
backendSource: record("alpha"),
|
||||
manifestOverrides: { dependencies: { beta: "^1.0.0" } },
|
||||
});
|
||||
createFixturePlugin({
|
||||
id: "beta",
|
||||
root: bundled,
|
||||
backendSource: record("beta"),
|
||||
});
|
||||
createFixturePlugin({
|
||||
id: "gamma",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { nowhere: "^1.0.0" } },
|
||||
});
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
const result = await loader.activateAll(["alpha", "beta", "gamma"]);
|
||||
|
||||
expect(result.activated).toEqual(["beta", "alpha"]);
|
||||
expect(fs.readFileSync(marker, "utf8").trim().split("\n")).toEqual([
|
||||
"beta",
|
||||
"alpha",
|
||||
]);
|
||||
expect(loader.get("gamma")?.state).toBe("blocked");
|
||||
expect(loader.get("gamma")?.lastError).toMatch(/not installed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginLoader lifecycle", () => {
|
||||
async function single(
|
||||
backendSource: string,
|
||||
id = "sample-plugin",
|
||||
): Promise<PluginLoader> {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({ id, root: bundled, backendSource });
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
return loader;
|
||||
}
|
||||
|
||||
it("runs activate and then deactivate", async () => {
|
||||
const marker = path.join(tempRoot("termix-life-"), "life.txt");
|
||||
const loader = await single(`
|
||||
import fs from "node:fs";
|
||||
export async function activate() {
|
||||
fs.appendFileSync(${JSON.stringify(marker)}, "up\\n");
|
||||
}
|
||||
export async function deactivate() {
|
||||
fs.appendFileSync(${JSON.stringify(marker)}, "down\\n");
|
||||
}
|
||||
`);
|
||||
|
||||
await loader.activate("sample-plugin");
|
||||
expect(loader.get("sample-plugin")?.state).toBe("active");
|
||||
|
||||
await loader.deactivate("sample-plugin");
|
||||
expect(loader.get("sample-plugin")?.state).toBe("stopped");
|
||||
expect(fs.readFileSync(marker, "utf8")).toBe("up\ndown\n");
|
||||
});
|
||||
|
||||
it("marks a plugin failed when activate throws", async () => {
|
||||
const loader = await single(`
|
||||
export async function activate() {
|
||||
throw new Error("no good");
|
||||
}
|
||||
`);
|
||||
|
||||
await expect(loader.activate("sample-plugin")).rejects.toThrow("no good");
|
||||
expect(loader.get("sample-plugin")?.state).toBe("failed");
|
||||
expect(loader.get("sample-plugin")?.lastError).toBe("no good");
|
||||
});
|
||||
|
||||
it("rejects a backend entry with no activate export", async () => {
|
||||
const loader = await single(`export const nothing = true;`);
|
||||
|
||||
await expect(loader.activate("sample-plugin")).rejects.toThrow(
|
||||
/does not export an activate/,
|
||||
);
|
||||
});
|
||||
|
||||
// The whole point of the bag: cleanup cannot be conditional on the plugin
|
||||
// behaving well on the way out.
|
||||
it("disposes everything even when deactivate throws", async () => {
|
||||
const marker = path.join(tempRoot("termix-dispose-"), "disposed.txt");
|
||||
const loader = await single(`
|
||||
import fs from "node:fs";
|
||||
export async function activate(ctx) {
|
||||
ctx.disposables.add(() => {
|
||||
fs.appendFileSync(${JSON.stringify(marker)}, "disposed\\n");
|
||||
});
|
||||
}
|
||||
export async function deactivate() {
|
||||
throw new Error("deactivate blew up");
|
||||
}
|
||||
`);
|
||||
|
||||
await loader.activate("sample-plugin");
|
||||
await loader.deactivate("sample-plugin");
|
||||
|
||||
expect(loader.get("sample-plugin")?.state).toBe("stopped");
|
||||
expect(fs.readFileSync(marker, "utf8")).toBe("disposed\n");
|
||||
});
|
||||
|
||||
// ESM caches the module, so the second activate runs against the same
|
||||
// module object the first one did. Anything created at module scope would
|
||||
// already be torn down by then.
|
||||
it("supports enable, disable, enable without a restart", async () => {
|
||||
const marker = path.join(tempRoot("termix-cycle-"), "cycle.txt");
|
||||
const loader = await single(`
|
||||
import fs from "node:fs";
|
||||
let timer = null;
|
||||
let server = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
server = { closed: false };
|
||||
timer = setInterval(() => {}, 1000);
|
||||
ctx.disposables.add(() => {
|
||||
clearInterval(timer);
|
||||
server.closed = true;
|
||||
fs.appendFileSync(${JSON.stringify(marker)}, "closed\\n");
|
||||
});
|
||||
fs.appendFileSync(${JSON.stringify(marker)}, "started\\n");
|
||||
}
|
||||
`);
|
||||
|
||||
await loader.activate("sample-plugin");
|
||||
await loader.deactivate("sample-plugin");
|
||||
await loader.activate("sample-plugin");
|
||||
|
||||
expect(loader.get("sample-plugin")?.state).toBe("active");
|
||||
expect(fs.readFileSync(marker, "utf8")).toBe("started\nclosed\nstarted\n");
|
||||
|
||||
await loader.deactivate("sample-plugin");
|
||||
});
|
||||
|
||||
it("disposes registrations made before activate threw", async () => {
|
||||
const marker = path.join(tempRoot("termix-partial-"), "partial.txt");
|
||||
const loader = await single(`
|
||||
import fs from "node:fs";
|
||||
export async function activate(ctx) {
|
||||
ctx.disposables.add(() => {
|
||||
fs.appendFileSync(${JSON.stringify(marker)}, "cleaned\\n");
|
||||
});
|
||||
throw new Error("halfway");
|
||||
}
|
||||
`);
|
||||
|
||||
await expect(loader.activate("sample-plugin")).rejects.toThrow("halfway");
|
||||
expect(fs.readFileSync(marker, "utf8")).toBe("cleaned\n");
|
||||
});
|
||||
|
||||
it("deactivating a plugin that never started is a no-op", async () => {
|
||||
const loader = await single(`export async function activate() {}`);
|
||||
|
||||
await loader.deactivate("sample-plugin");
|
||||
expect(loader.get("sample-plugin")?.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("shuts plugins down in reverse activation order", async () => {
|
||||
const marker = path.join(tempRoot("termix-shutdown-"), "shutdown.txt");
|
||||
const record = (id: string) => `
|
||||
import fs from "node:fs";
|
||||
export async function activate() {}
|
||||
export async function deactivate() {
|
||||
fs.appendFileSync(${JSON.stringify(marker)}, "${id}\\n");
|
||||
}
|
||||
`;
|
||||
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({
|
||||
id: "alpha",
|
||||
root: bundled,
|
||||
backendSource: record("alpha"),
|
||||
manifestOverrides: { dependencies: { beta: "^1.0.0" } },
|
||||
});
|
||||
createFixturePlugin({
|
||||
id: "beta",
|
||||
root: bundled,
|
||||
backendSource: record("beta"),
|
||||
});
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
await loader.activateAll(["alpha", "beta"]);
|
||||
await loader.shutdown();
|
||||
|
||||
// beta started first, so it stops last: a dependency outlives its
|
||||
// dependents.
|
||||
expect(fs.readFileSync(marker, "utf8").trim().split("\n")).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginLoader error budget", () => {
|
||||
async function failing(threshold: number): Promise<PluginLoader> {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({
|
||||
root: bundled,
|
||||
backendSource: `export async function activate() {}`,
|
||||
});
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader({
|
||||
errorThreshold: threshold,
|
||||
errorWindowMs: 60_000,
|
||||
});
|
||||
await loader.loadAll();
|
||||
await loader.activate("sample-plugin");
|
||||
return loader;
|
||||
}
|
||||
|
||||
it("marks a plugin failed once it trips the threshold", async () => {
|
||||
const loader = await failing(3);
|
||||
|
||||
await loader.reportError("sample-plugin", new Error("one"));
|
||||
await loader.reportError("sample-plugin", new Error("two"));
|
||||
expect(loader.get("sample-plugin")?.state).toBe("active");
|
||||
|
||||
await loader.reportError("sample-plugin", new Error("three"));
|
||||
|
||||
expect(loader.get("sample-plugin")?.state).toBe("failed");
|
||||
expect(loader.get("sample-plugin")?.lastError).toBe("three");
|
||||
});
|
||||
|
||||
it("forgets errors that fall outside the window", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({
|
||||
root: bundled,
|
||||
backendSource: `export async function activate() {}`,
|
||||
});
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader({ errorThreshold: 2, errorWindowMs: 10 });
|
||||
await loader.loadAll();
|
||||
await loader.activate("sample-plugin");
|
||||
|
||||
await loader.reportError("sample-plugin", new Error("one"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
await loader.reportError("sample-plugin", new Error("two"));
|
||||
|
||||
expect(loader.get("sample-plugin")?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("retry clears the budget and starts the plugin again", async () => {
|
||||
const loader = await failing(1);
|
||||
|
||||
await loader.reportError("sample-plugin", new Error("down"));
|
||||
expect(loader.get("sample-plugin")?.state).toBe("failed");
|
||||
|
||||
await loader.retry("sample-plugin");
|
||||
|
||||
expect(loader.get("sample-plugin")?.state).toBe("active");
|
||||
expect(loader.get("sample-plugin")?.lastError).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,854 @@
|
||||
/**
|
||||
* Manifest v2 validation.
|
||||
*
|
||||
* The rules live in the SDK so the server, the authoring script and plugin
|
||||
* authors share one implementation; these tests drive it through the server's
|
||||
* re-export, which is the path the loader actually takes.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseManifest,
|
||||
validateManifest,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
isTermixCompatible,
|
||||
} from "../../plugins/manifest.js";
|
||||
|
||||
function base(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "sample-plugin",
|
||||
name: "Sample Plugin",
|
||||
version: "1.0.0",
|
||||
description: "A fixture.",
|
||||
author: { name: "Termix Tests" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
|
||||
capabilities: ["kv:own"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("manifest v2 validation", () => {
|
||||
it("accepts a minimal valid manifest", () => {
|
||||
expect(validateManifest(base())).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports every missing required field at once", () => {
|
||||
const errors = validateManifest({ id: "sample-plugin" });
|
||||
|
||||
expect(errors.some((e) => e.includes('"name"'))).toBe(true);
|
||||
expect(errors.some((e) => e.includes('"version"'))).toBe(true);
|
||||
expect(errors.some((e) => e.includes('"capabilities"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an id that is not a lowercase slug", () => {
|
||||
expect(validateManifest(base({ id: "Sample_Plugin" })).join()).toMatch(
|
||||
/"id" must match/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an id that starts with a digit", () => {
|
||||
expect(validateManifest(base({ id: "1plugin" })).join()).toMatch(
|
||||
/"id" must match/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-semver version", () => {
|
||||
expect(validateManifest(base({ version: "1.0" })).join()).toMatch(
|
||||
/valid semver/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown category", () => {
|
||||
expect(validateManifest(base({ category: "Nonsense" })).join()).toMatch(
|
||||
/must be one of/,
|
||||
);
|
||||
});
|
||||
|
||||
// Capabilities were dotted in v1. They are colon-separated now so they can
|
||||
// never be confused with the dotted RBAC permissions.
|
||||
it("rejects a dotted capability name", () => {
|
||||
expect(
|
||||
validateManifest(base({ capabilities: ["hosts.read"] })).join(),
|
||||
).toMatch(/not a known capability/);
|
||||
});
|
||||
|
||||
it("rejects a capability outside the catalog", () => {
|
||||
expect(
|
||||
validateManifest(base({ capabilities: ["hosts:obliterate"] })).join(),
|
||||
).toMatch(/not a known capability/);
|
||||
});
|
||||
|
||||
it("rejects a duplicated capability", () => {
|
||||
expect(
|
||||
validateManifest(base({ capabilities: ["kv:own", "kv:own"] })).join(),
|
||||
).toMatch(/duplicates/);
|
||||
});
|
||||
|
||||
it("refuses a manifest targeting another SDK api version", () => {
|
||||
const { manifest, errors } = parseManifest(
|
||||
base({ engine: { termix: ">=2.9.0", api: "2" } }),
|
||||
);
|
||||
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(errors.join()).toMatch(/needs plugin API 2/);
|
||||
});
|
||||
|
||||
it("accepts an api range this build satisfies", () => {
|
||||
for (const api of ["1", "^1.0.0", ">=1.0.0 <2", "1.x"]) {
|
||||
expect(
|
||||
parseManifest(base({ engine: { termix: ">=2.9.0", api } })).errors,
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an api range needing a newer minor", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({ engine: { termix: ">=2.9.0", api: "^1.99" } }),
|
||||
);
|
||||
expect(errors.join()).toMatch(/needs plugin API \^1\.99/);
|
||||
});
|
||||
|
||||
it("refuses an engine that is not a semver range", () => {
|
||||
expect(
|
||||
validateManifest(base({ engine: { termix: "soon", api: "one" } })).join(),
|
||||
).toMatch(/engine.termix.*semver[\s\S]*engine.api.*semver/);
|
||||
});
|
||||
|
||||
it("checks engine.termix against the core version", () => {
|
||||
expect(isTermixCompatible(">=2.9.0", "2.9.0")).toBe(true);
|
||||
expect(isTermixCompatible(">=2.9.0", "2.9.0-beta.3")).toBe(true);
|
||||
expect(isTermixCompatible(">=3.0.0", "2.9.1")).toBe(false);
|
||||
expect(isTermixCompatible(">=3.0.0", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("fills in the entry point defaults", () => {
|
||||
const { manifest } = parseManifest(base());
|
||||
|
||||
expect(manifest?.backend).toBe("dist/backend.js");
|
||||
expect(manifest?.frontend).toBe("dist/frontend.js");
|
||||
expect(manifest?.locales).toBe("locales");
|
||||
});
|
||||
});
|
||||
|
||||
// v1 accepted unknown fields silently, so a typo'd "contribute" validated
|
||||
// clean and was then dropped. A manifest has to mean what it says.
|
||||
describe("unknown field rejection", () => {
|
||||
it("rejects an unknown top-level field", () => {
|
||||
expect(validateManifest(base({ sudoEverything: true })).join()).toMatch(
|
||||
/Unknown field "sudoEverything"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a near-miss of a real field name", () => {
|
||||
expect(validateManifest(base({ contribute: {} })).join()).toMatch(
|
||||
/Unknown field "contribute"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside contributes", () => {
|
||||
const errors = validateManifest(base({ contributes: { widgets: [] } }));
|
||||
|
||||
expect(errors.join()).toMatch(/Unknown field "widgets"/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside a tab", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
tabs: [
|
||||
{
|
||||
id: "t",
|
||||
titleKey: "k",
|
||||
icon: "i",
|
||||
openFrom: ["rail"],
|
||||
surprise: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/Unknown field "surprise"/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside author and engine", () => {
|
||||
expect(
|
||||
validateManifest(base({ author: { name: "x", role: "admin" } })).join(),
|
||||
).toMatch(/Unknown field "role"/);
|
||||
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ engine: { termix: ">=2.9.0", api: "1", unsafe: true } }),
|
||||
).join(),
|
||||
).toMatch(/Unknown field "unsafe"/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside a provides entry", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
provides: [
|
||||
{
|
||||
service: "sample.thing",
|
||||
version: "1.0.0",
|
||||
permission: "sample-plugin.use",
|
||||
elevated: true,
|
||||
},
|
||||
],
|
||||
contributes: {
|
||||
permissions: [
|
||||
{
|
||||
name: "use",
|
||||
titleKey: "permissions.use.title",
|
||||
descriptionKey: "permissions.use.description",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/Unknown field "elevated"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependencies", () => {
|
||||
it("accepts a well-formed dependency map", () => {
|
||||
expect(
|
||||
validateManifest(base({ dependencies: { automations: "^1.0.0" } })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a range that is not valid semver", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ dependencies: { automations: "latest!" } }),
|
||||
).join(),
|
||||
).toMatch(/valid semver range/);
|
||||
});
|
||||
|
||||
it("refuses a plugin depending on itself", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({ dependencies: { "sample-plugin": "^1.0.0" } }),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/points at this plugin/);
|
||||
});
|
||||
|
||||
it("refuses the same plugin in both dependency maps", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({
|
||||
dependencies: { automations: "^1.0.0" },
|
||||
optionalDependencies: { automations: "^1.0.0" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/both dependencies and optionalDependencies/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin permissions", () => {
|
||||
it("accepts declared permissions", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
permissions: [
|
||||
{
|
||||
name: "use",
|
||||
titleKey: "permissions.use.title",
|
||||
descriptionKey: "permissions.use.description",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a dotted name and a system role default", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
permissions: [
|
||||
{
|
||||
name: "services.use",
|
||||
titleKey: "permissions.services.use.title",
|
||||
descriptionKey: "permissions.services.use.description",
|
||||
defaultRoles: ["admin"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
// Without this a manifest could name a core group and then gate a route on
|
||||
// authority it was never given.
|
||||
it("refuses a name starting with a core group", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
permissions: [
|
||||
{
|
||||
name: "admin.users.manage",
|
||||
titleKey: "k",
|
||||
descriptionKey: "d",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/reserved core group/);
|
||||
});
|
||||
|
||||
it("refuses a name starting with hosts", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
permissions: [
|
||||
{ name: "hosts.view", titleKey: "k", descriptionKey: "d" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/reserved core group/);
|
||||
});
|
||||
|
||||
// "sample-plugin" + "sample-plugin.use" would register as
|
||||
// sample-plugin.sample-plugin.use, which is never the intent.
|
||||
it("refuses a name that repeats the plugin id", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
permissions: [
|
||||
{ name: "sample-plugin.use", titleKey: "k", descriptionKey: "d" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/already starts with this plugin/);
|
||||
});
|
||||
|
||||
it("refuses a default for a role core does not seed", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
permissions: [
|
||||
{
|
||||
name: "use",
|
||||
titleKey: "k",
|
||||
descriptionKey: "d",
|
||||
defaultRoles: ["superuser"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/defaultRoles\[0\]/);
|
||||
});
|
||||
|
||||
it("refuses a duplicate name", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
permissions: [
|
||||
{ name: "use", titleKey: "k", descriptionKey: "d" },
|
||||
{ name: "use", titleKey: "k2", descriptionKey: "d2" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/declared more than once/);
|
||||
});
|
||||
|
||||
it("requires both i18n keys", () => {
|
||||
const errors = validateManifest(
|
||||
base({ contributes: { permissions: [{ name: "use" }] } }),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/titleKey/);
|
||||
expect(errors.join()).toMatch(/descriptionKey/);
|
||||
});
|
||||
|
||||
// The id an admin grants is <pluginId>.<name>, so cross-field checks compare
|
||||
// against the qualified form rather than the short name.
|
||||
it("matches a service permission against the qualified id", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({
|
||||
provides: [
|
||||
{
|
||||
service: "sample.thing",
|
||||
version: "1.0.0",
|
||||
permission: "sample-plugin.use",
|
||||
},
|
||||
],
|
||||
contributes: {
|
||||
permissions: [{ name: "use", titleKey: "k", descriptionKey: "d" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("services, secrets and actions", () => {
|
||||
it("refuses a service gated by a permission the group never declares", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({
|
||||
provides: [
|
||||
{
|
||||
service: "sample.thing",
|
||||
version: "1.0.0",
|
||||
permission: "sample-plugin.missing",
|
||||
},
|
||||
],
|
||||
contributes: {
|
||||
permissions: [
|
||||
{
|
||||
name: "use",
|
||||
titleKey: "permissions.use.title",
|
||||
descriptionKey: "permissions.use.description",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/not declared in contributes/);
|
||||
});
|
||||
|
||||
it("refuses a shared secret gated by an undeclared permission", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({
|
||||
providesSecret: [
|
||||
{ key: "api-key", permission: "sample-plugin.missing" },
|
||||
],
|
||||
contributes: {
|
||||
permissions: [
|
||||
{
|
||||
name: "use",
|
||||
titleKey: "permissions.use.title",
|
||||
descriptionKey: "permissions.use.description",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/not declared in contributes/);
|
||||
});
|
||||
|
||||
it("refuses a plugin borrowing a secret from itself", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({ requiresSecret: [{ plugin: "sample-plugin", key: "api-key" }] }),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/points at this plugin/);
|
||||
});
|
||||
|
||||
it("refuses an action gated by an undeclared permission", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({
|
||||
contributes: {
|
||||
permissions: [
|
||||
{
|
||||
name: "use",
|
||||
titleKey: "permissions.use.title",
|
||||
descriptionKey: "permissions.use.description",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: "sample.open",
|
||||
titleKey: "k",
|
||||
handler: "open",
|
||||
permission: "sample-plugin.missing",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join()).toMatch(/not declared in contributes/);
|
||||
});
|
||||
|
||||
it("allows a camelCase segment in an action id", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
actions: [
|
||||
{ id: "ai.openWithContext", titleKey: "k", handler: "open" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an action id that is not dotted", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
actions: [{ id: "open", titleKey: "k", handler: "open" }],
|
||||
},
|
||||
}),
|
||||
).join(),
|
||||
).toMatch(/dotted action id/);
|
||||
});
|
||||
|
||||
it("rejects a handler that is not an identifier", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
actions: [
|
||||
{ id: "sample.open", titleKey: "k", handler: "not valid!" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).join(),
|
||||
).toMatch(/JavaScript identifier/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tabs and host capabilities", () => {
|
||||
it("rejects an unknown openFrom value", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
tabs: [
|
||||
{ id: "t", titleKey: "k", icon: "i", openFrom: ["telepathy"] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).join(),
|
||||
).toMatch(/openFrom\[0\] must be one of/);
|
||||
});
|
||||
|
||||
it("rejects duplicate tab ids", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
tabs: [
|
||||
{ id: "t", titleKey: "k", icon: "i", openFrom: ["rail"] },
|
||||
{ id: "t", titleKey: "k2", icon: "i", openFrom: ["rail"] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).join(),
|
||||
).toMatch(/duplicates/);
|
||||
});
|
||||
|
||||
it("accepts hostCapability as a single object or an array", () => {
|
||||
const one = {
|
||||
key: "enableThing",
|
||||
labelKey: "k",
|
||||
editorTab: "general",
|
||||
};
|
||||
|
||||
expect(
|
||||
validateManifest(base({ contributes: { hostCapability: one } })),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
hostCapability: [one, { ...one, key: "enableOther" }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects duplicate hostCapability keys", () => {
|
||||
const one = { key: "enableThing", labelKey: "k", editorTab: "general" };
|
||||
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { hostCapability: [one, one] } }),
|
||||
).join(),
|
||||
).toMatch(/duplicates/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("panels, dashboard cards and guest pages", () => {
|
||||
it("accepts declared panels, cards and the guest flag", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [{ id: "sample-panel", titleKey: "nav.panel" }],
|
||||
dashboardCards: [
|
||||
{ id: "sample-card", titleKey: "cards.sample", icon: "Box" },
|
||||
],
|
||||
guest: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects duplicate view ids and missing title keys", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [
|
||||
{ id: "p", titleKey: "a" },
|
||||
{ id: "p", titleKey: "b" },
|
||||
],
|
||||
dashboardCards: [{ id: "c" }],
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/panels\[1\]\.id duplicates "p"/);
|
||||
expect(errors).toMatch(/dashboardCards\[0\]\.titleKey/);
|
||||
});
|
||||
|
||||
it("accepts guest views on a guest plugin and refuses them otherwise", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { guest: true, guestViews: ["shared"] } }),
|
||||
),
|
||||
).toEqual([]);
|
||||
const errors = validateManifest(
|
||||
base({ contributes: { guestViews: ["Shared View"] } }),
|
||||
).join();
|
||||
expect(errors).toMatch(/contributes\.guestViews entries must be/);
|
||||
expect(errors).toMatch(/needs "contributes\.guest"/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside a view and a non-boolean guest", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [{ id: "p", titleKey: "a", width: 3 }],
|
||||
guest: "yes",
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/Unknown field "width"/);
|
||||
expect(errors).toMatch(/contributes\.guest" must be a boolean/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("named service providers", () => {
|
||||
const provides = (names: unknown) => [
|
||||
{
|
||||
service: "sample.live",
|
||||
version: "1.0.0",
|
||||
permission: "sample-plugin.use",
|
||||
names,
|
||||
},
|
||||
];
|
||||
const permissions = {
|
||||
permissions: [
|
||||
{
|
||||
name: "use",
|
||||
titleKey: "permissions.use.title",
|
||||
descriptionKey: "permissions.use.description",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("accepts a list of provider names", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ provides: provides(["ssh", "rdp"]), contributes: permissions }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an empty, duplicated or malformed list", () => {
|
||||
for (const names of [[], ["ssh", "ssh"], ["SSH"], "ssh"]) {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ provides: provides(names), contributes: permissions }),
|
||||
).join(),
|
||||
).toMatch(/provides\[0\]\.names/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.http.legacyRedirects", () => {
|
||||
const withRedirects = (legacyRedirects: unknown) =>
|
||||
validateManifest(base({ contributes: { http: { legacyRedirects } } }));
|
||||
|
||||
it("accepts an old URL outside the plugin's namespace", () => {
|
||||
expect(
|
||||
withRedirects([
|
||||
{ from: "/users/oidc/callback", to: "/callback", status: 308 },
|
||||
{ from: "/host/old-callback", to: "/callback" },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses the plugin framework's own prefixes", () => {
|
||||
expect(
|
||||
withRedirects([{ from: "/plugin-api/other/x", to: "/x" }]).join(),
|
||||
).toMatch(/cannot be under plugin-api/);
|
||||
});
|
||||
|
||||
it("refuses a relative path, a bad status and unknown keys", () => {
|
||||
const errors = withRedirects([
|
||||
{ from: "users/x", to: "/x" },
|
||||
{ from: "/a", to: "x" },
|
||||
{ from: "/a", to: "/x", status: 302 },
|
||||
{ from: "/a", to: "/x", extra: true },
|
||||
]).join();
|
||||
expect(errors).toMatch(/from must be an absolute path/);
|
||||
expect(errors).toMatch(/to must be a path/);
|
||||
expect(errors).toMatch(/status must be 307 or 308/);
|
||||
expect(errors).toMatch(/extra/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.http.adminImpersonation", () => {
|
||||
it("accepts a boolean and refuses anything else", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { http: { adminImpersonation: true } } }),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { http: { adminImpersonation: "yes" } } }),
|
||||
).join(),
|
||||
).toMatch(/adminImpersonation" must be a boolean/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.keybindingActions", () => {
|
||||
const withActions = (keybindingActions: unknown) =>
|
||||
validateManifest(base({ contributes: { keybindingActions } }));
|
||||
|
||||
it("accepts actions with typed parameters", () => {
|
||||
expect(
|
||||
withActions([
|
||||
{ id: "sample.run" },
|
||||
{
|
||||
id: "sample.send",
|
||||
params: {
|
||||
itemId: {
|
||||
type: "string",
|
||||
required: true,
|
||||
pattern: "^[0-9]+$",
|
||||
maxLength: 20,
|
||||
syncEntity: "items",
|
||||
},
|
||||
enter: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses the shell's own actions, duplicates and bad ids", () => {
|
||||
const errors = withActions([
|
||||
{ id: "nextTab" },
|
||||
{ id: "sample.run" },
|
||||
{ id: "sample.run" },
|
||||
{ id: "bad id" },
|
||||
]).join();
|
||||
expect(errors).toMatch(/one of the shell's own actions/);
|
||||
expect(errors).toMatch(/duplicates "sample.run"/);
|
||||
expect(errors).toMatch(/letters, digits, dots and dashes/);
|
||||
});
|
||||
|
||||
it("refuses bad parameters", () => {
|
||||
const errors = withActions([
|
||||
{
|
||||
id: "sample.run",
|
||||
params: {
|
||||
type: { type: "string" },
|
||||
a: { type: "number" },
|
||||
b: { type: "string", pattern: "(" },
|
||||
c: { type: "boolean", syncEntity: "items" },
|
||||
d: { type: "string", extra: 1 },
|
||||
},
|
||||
},
|
||||
]).join();
|
||||
expect(errors).toMatch(/params.type is not a valid parameter name/);
|
||||
expect(errors).toMatch(/a.type must be "string" or "boolean"/);
|
||||
expect(errors).toMatch(/b.pattern must be a regular expression/);
|
||||
expect(errors).toMatch(/c.syncEntity needs a string parameter/);
|
||||
expect(errors).toMatch(/d/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.protocols", () => {
|
||||
const withProtocols = (protocols: unknown) =>
|
||||
validateManifest(base({ contributes: { protocols } }));
|
||||
|
||||
it("accepts protocols with credential fields", () => {
|
||||
expect(
|
||||
withProtocols([
|
||||
{
|
||||
id: "spice",
|
||||
credentialFields: [
|
||||
{ key: "display" },
|
||||
{ key: "ticket", secret: true },
|
||||
],
|
||||
defaultPort: 5930,
|
||||
hostLoginFallback: ["username", "password"],
|
||||
},
|
||||
{ id: "x2go" },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses core's protocol, duplicates and bad ids", () => {
|
||||
const errors = withProtocols([
|
||||
{ id: "ssh" },
|
||||
{ id: "spice" },
|
||||
{ id: "spice" },
|
||||
{ id: "Bad_Id" },
|
||||
]);
|
||||
expect(errors.join("\n")).toMatch(/"ssh" belongs to core/);
|
||||
expect(errors.join("\n")).toMatch(/duplicates "spice"/);
|
||||
expect(errors.join("\n")).toMatch(/\[3\]\.id must be lowercase/);
|
||||
});
|
||||
|
||||
it("refuses field keys every login already has, and duplicate keys", () => {
|
||||
const errors = withProtocols([
|
||||
{
|
||||
id: "spice",
|
||||
credentialFields: [
|
||||
{ key: "password" },
|
||||
{ key: "display" },
|
||||
{ key: "display", secret: "yes" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(errors.join("\n")).toMatch(/"password" is part of every login/);
|
||||
expect(errors.join("\n")).toMatch(/duplicates "display"/);
|
||||
expect(errors.join("\n")).toMatch(/secret must be a boolean/);
|
||||
});
|
||||
|
||||
it("refuses unknown keys, bad ports and bad fallbacks", () => {
|
||||
const errors = withProtocols([
|
||||
{
|
||||
id: "spice",
|
||||
defaultPort: 70000,
|
||||
hostLoginFallback: ["key"],
|
||||
extra: true,
|
||||
},
|
||||
]);
|
||||
expect(errors.join("\n")).toMatch(/defaultPort must be a port number/);
|
||||
expect(errors.join("\n")).toMatch(/hostLoginFallback/);
|
||||
expect(errors.join("\n")).toMatch(/extra/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
applyPluginMigrations,
|
||||
assertOwnedTables,
|
||||
adoptLegacyTableSql,
|
||||
checksum,
|
||||
PluginMigrationChecksumError,
|
||||
readMigrations,
|
||||
splitStatements,
|
||||
LEGACY_TABLE_OWNERS,
|
||||
type MigrationRunner,
|
||||
type PluginMigration,
|
||||
} from "../../plugins/migrations.js";
|
||||
|
||||
let root: string;
|
||||
|
||||
function writeMigration(
|
||||
pluginDir: string,
|
||||
dialect: string,
|
||||
file: string,
|
||||
body: string,
|
||||
): void {
|
||||
const dir = path.join(pluginDir, "migrations", dialect);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), body);
|
||||
}
|
||||
|
||||
function createRunner(overrides: Partial<MigrationRunner> = {}) {
|
||||
const executed: string[][] = [];
|
||||
const recorded: PluginMigration[] = [];
|
||||
const applied: { migrationId: string; checksum: string }[] = [];
|
||||
|
||||
const runner: MigrationRunner = {
|
||||
dialect: "sqlite",
|
||||
listApplied: async () => applied,
|
||||
execute: async (statements) => {
|
||||
executed.push(statements);
|
||||
},
|
||||
record: async (_pluginId, migration) => {
|
||||
recorded.push(migration);
|
||||
applied.push({
|
||||
migrationId: migration.id,
|
||||
checksum: migration.checksum,
|
||||
});
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { runner, executed, recorded, applied };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-migrations-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("readMigrations", () => {
|
||||
it("reads files in sequence order regardless of directory order", () => {
|
||||
writeMigration(root, "sqlite", "0002_second.sql", "SELECT 2;");
|
||||
writeMigration(root, "sqlite", "0010_tenth.sql", "SELECT 10;");
|
||||
writeMigration(root, "sqlite", "0001_first.sql", "SELECT 1;");
|
||||
|
||||
const migrations = readMigrations(root, "sqlite");
|
||||
|
||||
expect(migrations.map((m) => m.id)).toEqual([
|
||||
"0001_first",
|
||||
"0002_second",
|
||||
"0010_tenth",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing when the plugin ships no migrations", () => {
|
||||
expect(readMigrations(root, "sqlite")).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a .sql file that is not named NNNN_name.sql", () => {
|
||||
writeMigration(root, "sqlite", "init.sql", "SELECT 1;");
|
||||
|
||||
expect(() => readMigrations(root, "sqlite")).toThrow(/NNNN_name\.sql/);
|
||||
});
|
||||
|
||||
it("is insensitive to line endings, so a checkout does not look edited", () => {
|
||||
expect(checksum("CREATE TABLE a;\nSELECT 1;\n")).toBe(
|
||||
checksum("CREATE TABLE a;\r\nSELECT 1;\r\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitStatements", () => {
|
||||
it("splits on semicolons", () => {
|
||||
expect(splitStatements("SELECT 1; SELECT 2;")).toEqual([
|
||||
"SELECT 1",
|
||||
"SELECT 2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a semicolon inside a string literal", () => {
|
||||
const statements = splitStatements(
|
||||
"INSERT INTO t VALUES ('a;b'); SELECT 1;",
|
||||
);
|
||||
|
||||
expect(statements).toEqual(["INSERT INTO t VALUES ('a;b')", "SELECT 1"]);
|
||||
});
|
||||
|
||||
it("keeps an escaped quote inside a string literal", () => {
|
||||
const statements = splitStatements("INSERT INTO t VALUES ('it''s; fine');");
|
||||
|
||||
expect(statements).toEqual(["INSERT INTO t VALUES ('it''s; fine')"]);
|
||||
});
|
||||
|
||||
it("keeps a semicolon inside a line comment", () => {
|
||||
const statements = splitStatements("-- a; comment\nSELECT 1;");
|
||||
|
||||
expect(statements).toHaveLength(1);
|
||||
expect(statements[0]).toContain("SELECT 1");
|
||||
});
|
||||
|
||||
it("keeps a semicolon inside a block comment", () => {
|
||||
const statements = splitStatements("/* a; b */ SELECT 1;");
|
||||
|
||||
expect(statements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores a trailing statement that is only whitespace", () => {
|
||||
expect(splitStatements("SELECT 1;\n\n \n")).toEqual(["SELECT 1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertOwnedTables", () => {
|
||||
it("allows a table in the plugin's own namespace", () => {
|
||||
expect(() =>
|
||||
assertOwnedTables(
|
||||
"my-plugin",
|
||||
"CREATE TABLE IF NOT EXISTS p_my_plugin_thing (id integer)",
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a table outside the plugin's namespace", () => {
|
||||
expect(() =>
|
||||
assertOwnedTables("my-plugin", "CREATE TABLE users (id integer)"),
|
||||
).toThrow(/"users", which is not prefixed "p_my_plugin_"/);
|
||||
});
|
||||
|
||||
it("rejects altering a core table", () => {
|
||||
expect(() =>
|
||||
assertOwnedTables("my-plugin", "ALTER TABLE ssh_data ADD COLUMN x text"),
|
||||
).toThrow(/p_my_plugin_/);
|
||||
});
|
||||
|
||||
it("allows the bundled plugin that adopts a legacy table", () => {
|
||||
expect(() =>
|
||||
assertOwnedTables(
|
||||
"fleets",
|
||||
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"',
|
||||
{ bundled: true },
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("gives a user plugin with that id no legacy exemption", () => {
|
||||
expect(() =>
|
||||
assertOwnedTables(
|
||||
"fleets",
|
||||
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"',
|
||||
),
|
||||
).toThrow(/"fleets", which is not prefixed/);
|
||||
});
|
||||
|
||||
it("names the owner when another plugin tries to adopt its table", () => {
|
||||
expect(() =>
|
||||
assertOwnedTables(
|
||||
"workspaces",
|
||||
'ALTER TABLE "fleets" RENAME TO "p_workspaces_fleets"',
|
||||
{ bundled: true },
|
||||
),
|
||||
).toThrow(/belongs to the "fleets" plugin/);
|
||||
});
|
||||
|
||||
it("maps every legacy table to exactly one owning plugin", () => {
|
||||
// A table owned by two plugins would let either one adopt it and lose the
|
||||
// other's data.
|
||||
const owners = Object.entries(LEGACY_TABLE_OWNERS);
|
||||
expect(new Set(owners.map(([table]) => table)).size).toBe(owners.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPluginMigrations", () => {
|
||||
it("applies pending migrations in order and records each one", async () => {
|
||||
writeMigration(
|
||||
root,
|
||||
"sqlite",
|
||||
"0001_init.sql",
|
||||
"CREATE TABLE p_demo_a (id integer);",
|
||||
);
|
||||
writeMigration(
|
||||
root,
|
||||
"sqlite",
|
||||
"0002_more.sql",
|
||||
"CREATE TABLE p_demo_b (id integer);",
|
||||
);
|
||||
const { runner, recorded } = createRunner();
|
||||
|
||||
const ids = await applyPluginMigrations("demo", root, runner);
|
||||
|
||||
expect(ids).toEqual(["0001_init", "0002_more"]);
|
||||
expect(recorded.map((m) => m.id)).toEqual(["0001_init", "0002_more"]);
|
||||
});
|
||||
|
||||
it("skips a migration that has already been applied", async () => {
|
||||
writeMigration(
|
||||
root,
|
||||
"sqlite",
|
||||
"0001_init.sql",
|
||||
"CREATE TABLE p_demo_a (id integer);",
|
||||
);
|
||||
const body = fs.readFileSync(
|
||||
path.join(root, "migrations", "sqlite", "0001_init.sql"),
|
||||
"utf8",
|
||||
);
|
||||
const { runner, executed } = createRunner({
|
||||
listApplied: async () => [
|
||||
{ migrationId: "0001_init", checksum: checksum(body) },
|
||||
],
|
||||
});
|
||||
|
||||
const ids = await applyPluginMigrations("demo", root, runner);
|
||||
|
||||
expect(ids).toEqual([]);
|
||||
expect(executed).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to run when an applied migration was edited", async () => {
|
||||
writeMigration(
|
||||
root,
|
||||
"sqlite",
|
||||
"0001_init.sql",
|
||||
"CREATE TABLE p_demo_a (id integer);",
|
||||
);
|
||||
const { runner } = createRunner({
|
||||
listApplied: async () => [
|
||||
{ migrationId: "0001_init", checksum: "stale-checksum" },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(applyPluginMigrations("demo", root, runner)).rejects.toThrow(
|
||||
PluginMigrationChecksumError,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not record a migration whose statements failed", async () => {
|
||||
writeMigration(
|
||||
root,
|
||||
"sqlite",
|
||||
"0001_init.sql",
|
||||
"CREATE TABLE p_demo_a (id integer);",
|
||||
);
|
||||
const { runner, recorded } = createRunner({
|
||||
execute: async () => {
|
||||
throw new Error("syntax error");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(applyPluginMigrations("demo", root, runner)).rejects.toThrow(
|
||||
"syntax error",
|
||||
);
|
||||
expect(recorded).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a migration that reaches outside the plugin's namespace", async () => {
|
||||
writeMigration(root, "sqlite", "0001_init.sql", "DROP TABLE users;");
|
||||
const { runner, executed } = createRunner();
|
||||
|
||||
await expect(applyPluginMigrations("demo", root, runner)).rejects.toThrow(
|
||||
/p_demo_/,
|
||||
);
|
||||
expect(executed).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs inside a transaction when the dialect offers one", async () => {
|
||||
writeMigration(
|
||||
root,
|
||||
"sqlite",
|
||||
"0001_init.sql",
|
||||
"CREATE TABLE p_demo_a (id integer);",
|
||||
);
|
||||
const order: string[] = [];
|
||||
const { runner } = createRunner({
|
||||
transaction: async (fn) => {
|
||||
order.push("begin");
|
||||
await fn();
|
||||
order.push("commit");
|
||||
},
|
||||
execute: async () => {
|
||||
order.push("execute");
|
||||
},
|
||||
});
|
||||
|
||||
await applyPluginMigrations("demo", root, runner);
|
||||
|
||||
expect(order).toEqual(["begin", "execute", "commit"]);
|
||||
});
|
||||
|
||||
it("reads the directory for the dialect it was given", async () => {
|
||||
writeMigration(
|
||||
root,
|
||||
"sqlite",
|
||||
"0001_init.sql",
|
||||
"CREATE TABLE p_demo_a (x);",
|
||||
);
|
||||
writeMigration(
|
||||
root,
|
||||
"mysql",
|
||||
"0001_init.sql",
|
||||
"CREATE TABLE p_demo_a (x int);",
|
||||
);
|
||||
const { runner, executed } = createRunner({ dialect: "mysql" });
|
||||
|
||||
await applyPluginMigrations("demo", root, runner);
|
||||
|
||||
expect(executed[0][0]).toContain("int");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adoptLegacyTableSql", () => {
|
||||
it("uses ALTER TABLE RENAME on sqlite and postgres", () => {
|
||||
expect(adoptLegacyTableSql("sqlite", "fleets", "p_fleets_fleets")).toBe(
|
||||
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";',
|
||||
);
|
||||
expect(adoptLegacyTableSql("postgres", "fleets", "p_fleets_fleets")).toBe(
|
||||
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";',
|
||||
);
|
||||
});
|
||||
|
||||
it("uses RENAME TABLE and backticks on mysql", () => {
|
||||
expect(adoptLegacyTableSql("mysql", "fleets", "p_fleets_fleets")).toBe(
|
||||
"RENAME TABLE `fleets` TO `p_fleets_fleets`;",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* A5: plugin permissions in core RBAC.
|
||||
*
|
||||
* Covers the four things that were structurally wrong before: a plugin could
|
||||
* name any namespace it liked, a disabled plugin's permissions stopped being
|
||||
* grantable, a revoked role default came back on the next boot, and the ai
|
||||
* group was declared twice.
|
||||
*/
|
||||
|
||||
import type { PluginLoader } from "../../plugins/loader.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Fixture } from "./fixture-plugin.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
/** roleName -> the permission array stored on that role. */
|
||||
roles: new Map<string, { id: number; permissions: string[] }>(),
|
||||
/** The rbac_applied_defaults rows, as "<role>:<permission>". */
|
||||
appliedDefaults: new Set<string>(),
|
||||
/** The rbac_known_permissions rows. */
|
||||
known: new Set<string>(),
|
||||
warnings: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../upgrade/boot-migrations.js", () => ({
|
||||
runPluginDataMigrations: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn((message: string) => {
|
||||
state.warnings.push(message);
|
||||
}),
|
||||
error: vi.fn((message: string) => {
|
||||
state.warnings.push(message);
|
||||
}),
|
||||
success: vi.fn(),
|
||||
},
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => {}),
|
||||
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async () => true,
|
||||
invalidateUserPermissionCache: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentRoleRepository: () => ({
|
||||
findRoleByName: async (name: string) => {
|
||||
const role = state.roles.get(name);
|
||||
if (!role) return null;
|
||||
return {
|
||||
id: role.id,
|
||||
name,
|
||||
permissions: JSON.stringify(role.permissions),
|
||||
};
|
||||
},
|
||||
updateRole: async (id: number, updates: { permissions?: string }) => {
|
||||
for (const [name, role] of state.roles) {
|
||||
if (role.id !== id) continue;
|
||||
if (updates.permissions === undefined) continue;
|
||||
state.roles.set(name, {
|
||||
id,
|
||||
permissions: JSON.parse(updates.permissions) as string[],
|
||||
});
|
||||
}
|
||||
},
|
||||
listRoleUserIds: async () => [],
|
||||
listRoles: async () =>
|
||||
[...state.roles.entries()].map(([name, role]) => ({
|
||||
id: role.id,
|
||||
name,
|
||||
permissions: JSON.stringify(role.permissions),
|
||||
})),
|
||||
}),
|
||||
createCurrentRbacPermissionRepository: () => ({
|
||||
listKnown: async () =>
|
||||
[...state.known].map((permission) => ({ permission, pluginId: null })),
|
||||
recordKnown: async (rows: { permission: string }[]) => {
|
||||
for (const row of rows) state.known.add(row.permission);
|
||||
return rows.length;
|
||||
},
|
||||
listAppliedDefaults: async () =>
|
||||
[...state.appliedDefaults].map((key) => {
|
||||
const [roleName, ...rest] = key.split(":");
|
||||
return { roleName, permission: rest.join(":") };
|
||||
}),
|
||||
recordAppliedDefaults: async (
|
||||
rows: { roleName: string; permission: string }[],
|
||||
) => {
|
||||
for (const row of rows) {
|
||||
state.appliedDefaults.add(`${row.roleName}:${row.permission}`);
|
||||
}
|
||||
return rows.length;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createFixturePlugin } = await import("./fixture-plugin.js");
|
||||
const {
|
||||
getPermissionCatalog,
|
||||
isValidPermission,
|
||||
markPluginPermissionsDisabled,
|
||||
registerPluginPermissions,
|
||||
resetPermissionCatalog,
|
||||
} = await import("../../utils/permission-catalog.js");
|
||||
const { parseManifest, qualifyPermission } =
|
||||
await import("../../plugins/manifest.js");
|
||||
const { resolvePermission, declaredPermissions } =
|
||||
await import("../../plugins/rbac.js");
|
||||
const { createRbacMiddleware } = await import("../../plugins/http.js");
|
||||
|
||||
function manifest(
|
||||
id: string,
|
||||
permissions: Record<string, unknown>[],
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
description: "A fixture.",
|
||||
author: { name: "Termix" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: ["kv:own"],
|
||||
contributes: { permissions },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function permission(name: string, defaultRoles?: string[]) {
|
||||
const entry: Record<string, unknown> = {
|
||||
name,
|
||||
titleKey: `permissions.${name}.title`,
|
||||
descriptionKey: `permissions.${name}.description`,
|
||||
};
|
||||
if (defaultRoles) entry.defaultRoles = defaultRoles;
|
||||
return entry;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.roles.clear();
|
||||
state.appliedDefaults.clear();
|
||||
state.known.clear();
|
||||
state.warnings.length = 0;
|
||||
resetPermissionCatalog();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetPermissionCatalog();
|
||||
});
|
||||
|
||||
describe("namespacing", () => {
|
||||
it("registers a short name as <pluginId>.<name>", () => {
|
||||
expect(qualifyPermission("ai", "services.use")).toBe("ai.services.use");
|
||||
expect(qualifyPermission("ai", "use")).toBe("ai.use");
|
||||
});
|
||||
|
||||
it("qualifies the ids a manifest declares", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest("ai", [permission("use"), permission("services.use")]),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(
|
||||
parsed?.contributes?.permissions?.map((entry) =>
|
||||
qualifyPermission("ai", entry.name),
|
||||
),
|
||||
).toEqual(["ai.use", "ai.services.use"]);
|
||||
});
|
||||
|
||||
it("prefixes a bare short name and leaves a full id alone", () => {
|
||||
const parsed = parseManifest(
|
||||
manifest("ai", [permission("services.use")]),
|
||||
).manifest!;
|
||||
|
||||
expect(resolvePermission(parsed, "services.use")).toBe("ai.services.use");
|
||||
// Its own full id is already qualified.
|
||||
expect(resolvePermission(parsed, "ai.services.use")).toBe(
|
||||
"ai.services.use",
|
||||
);
|
||||
// A core group is used as given.
|
||||
expect(resolvePermission(parsed, "hosts.view")).toBe("hosts.view");
|
||||
});
|
||||
|
||||
it("leaves another plugin's id alone, for a cross-plugin check", () => {
|
||||
registerPluginPermissions({
|
||||
group: "automations",
|
||||
pluginId: "automations",
|
||||
label: "Automations",
|
||||
permissions: ["automations.run"],
|
||||
});
|
||||
|
||||
const parsed = parseManifest(manifest("ai", [permission("use")])).manifest!;
|
||||
|
||||
expect(resolvePermission(parsed, "automations.run")).toBe(
|
||||
"automations.run",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("namespace escalation is refused", () => {
|
||||
it.each([
|
||||
["admin.users.manage", /reserved core group/],
|
||||
["hosts.view", /reserved core group/],
|
||||
["credentials.read", /reserved core group/],
|
||||
["admin.plugins.manage", /reserved core group/],
|
||||
])("refuses the core name %s", (name, pattern) => {
|
||||
const { errors } = parseManifest(manifest("sample", [permission(name)]));
|
||||
|
||||
expect(errors.join()).toMatch(pattern);
|
||||
});
|
||||
|
||||
it("refuses a name that repeats the plugin's own id", () => {
|
||||
const { errors } = parseManifest(manifest("ai", [permission("ai.use")]));
|
||||
|
||||
expect(errors.join()).toMatch(/already starts with this plugin/);
|
||||
});
|
||||
|
||||
// The manifest validator cannot know which other plugins exist, so the
|
||||
// runtime re-checks against the live catalog when the plugin registers.
|
||||
it("drops a permission naming another plugin's namespace at registration", async () => {
|
||||
registerPluginPermissions({
|
||||
group: "automations",
|
||||
pluginId: "automations",
|
||||
label: "Automations",
|
||||
permissions: ["automations.run"],
|
||||
});
|
||||
|
||||
const fixture = createFixturePlugin({
|
||||
id: "docker",
|
||||
capabilities: ["kv:own"],
|
||||
manifestOverrides: {
|
||||
category: "Infrastructure",
|
||||
contributes: {
|
||||
permissions: [permission("automations.run"), permission("ps")],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { resetPluginRuntime, getPluginRuntime, activatePlugin } =
|
||||
await import("../../plugins/index.js");
|
||||
resetPluginRuntime();
|
||||
const loader = getPluginRuntime().loader;
|
||||
|
||||
try {
|
||||
const plugin = await loader.load(fixture.dir);
|
||||
await activatePlugin(plugin.id);
|
||||
|
||||
// Its own name registered; the one reaching into automations did not.
|
||||
expect(isValidPermission("docker.ps")).toBe(true);
|
||||
expect(isValidPermission("docker.automations.run")).toBe(false);
|
||||
expect(state.warnings.join()).toMatch(/belongs to someone else/);
|
||||
const { listRegistrationConflicts } =
|
||||
await import("../../plugins/conflicts.js");
|
||||
expect(listRegistrationConflicts()).toContainEqual(
|
||||
expect.objectContaining({ kind: "permission", heldBy: "automations" }),
|
||||
);
|
||||
} finally {
|
||||
await loader.shutdown();
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("only lets a route require a permission the plugin declares", () => {
|
||||
const parsed = parseManifest(manifest("ai", [permission("use")])).manifest!;
|
||||
|
||||
expect(declaredPermissions(parsed).has("ai.use")).toBe(true);
|
||||
expect(() => createRbacMiddleware(parsed, "use")).not.toThrow();
|
||||
expect(() => createRbacMiddleware(parsed, "admin.users.manage")).toThrow(
|
||||
/cannot require permission/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a disabled plugin's permissions survive", () => {
|
||||
beforeEach(() => {
|
||||
registerPluginPermissions({
|
||||
group: "ai",
|
||||
pluginId: "ai",
|
||||
label: "AI Assistant",
|
||||
icon: "Sparkles",
|
||||
permissions: ["ai.use", "ai.services.use"],
|
||||
});
|
||||
});
|
||||
|
||||
// The exact shape PUT /rbac/roles/:id validates, which used to 400 the whole
|
||||
// role while the plugin was off.
|
||||
it("keeps validating a role that holds them", () => {
|
||||
markPluginPermissionsDisabled("ai");
|
||||
|
||||
const saved = ["hosts.view", "ai.use", "ai.services.use"];
|
||||
const invalid = saved.filter((entry) => !isValidPermission(entry));
|
||||
|
||||
expect(invalid).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the group listed, marked disabled", () => {
|
||||
markPluginPermissionsDisabled("ai");
|
||||
|
||||
const entry = getPermissionCatalog().find((row) => row.group === "ai");
|
||||
expect(entry?.enabled).toBe(false);
|
||||
expect(entry?.permissions).toContain("ai.use");
|
||||
});
|
||||
|
||||
it("re-enables on the next registration", () => {
|
||||
markPluginPermissionsDisabled("ai");
|
||||
registerPluginPermissions({
|
||||
group: "ai",
|
||||
pluginId: "ai",
|
||||
label: "AI Assistant",
|
||||
permissions: ["ai.use", "ai.services.use"],
|
||||
});
|
||||
|
||||
const entry = getPermissionCatalog().find((row) => row.group === "ai");
|
||||
expect(entry?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the duplicate ai group is gone", () => {
|
||||
it("lists ai exactly once when the plugin registers", () => {
|
||||
registerPluginPermissions({
|
||||
group: "ai",
|
||||
pluginId: "ai",
|
||||
label: "AI Assistant",
|
||||
permissions: ["ai.use"],
|
||||
});
|
||||
|
||||
const matches = getPermissionCatalog().filter(
|
||||
(entry) => entry.group === "ai",
|
||||
);
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].pluginId).toBe("ai");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shipped manifests keep their permission ids", () => {
|
||||
const root = path.resolve(import.meta.dirname, "../../../../plugins");
|
||||
|
||||
function idsFor(pluginId: string): string[] {
|
||||
const raw = JSON.parse(
|
||||
fs.readFileSync(path.join(root, pluginId, "manifest.json"), "utf8"),
|
||||
);
|
||||
const { manifest: parsed, errors } = parseManifest(raw);
|
||||
expect(errors).toEqual([]);
|
||||
return (parsed?.contributes?.permissions ?? []).map((entry) =>
|
||||
qualifyPermission(pluginId, entry.name),
|
||||
);
|
||||
}
|
||||
|
||||
// A role holding these today must keep working, which is only true because
|
||||
// <pluginId>.<name> reproduces the old id byte for byte.
|
||||
it("ai", () => {
|
||||
expect(idsFor("ai")).toEqual([
|
||||
"ai.use",
|
||||
"ai.manage_providers",
|
||||
"ai.apply_proposals",
|
||||
"ai.services.use",
|
||||
"ai.secrets.share",
|
||||
]);
|
||||
});
|
||||
|
||||
it("automations", () => {
|
||||
expect(idsFor("automations")).toEqual([
|
||||
"automations.view",
|
||||
"automations.create",
|
||||
"automations.edit",
|
||||
"automations.delete",
|
||||
"automations.run",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tailscale", () => {
|
||||
expect(idsFor("tailscale")).toEqual(["tailscale.devices.view"]);
|
||||
});
|
||||
|
||||
it("snippets", () => {
|
||||
expect(idsFor("snippets")).toEqual([
|
||||
"snippets.view",
|
||||
"snippets.create",
|
||||
"snippets.edit",
|
||||
"snippets.delete",
|
||||
"snippets.share",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a role holding the old ids still validates once registered", () => {
|
||||
for (const pluginId of ["ai", "automations", "tailscale", "snippets"]) {
|
||||
const ids = idsFor(pluginId);
|
||||
registerPluginPermissions({
|
||||
group: pluginId,
|
||||
pluginId,
|
||||
label: pluginId,
|
||||
permissions: ids,
|
||||
});
|
||||
}
|
||||
|
||||
const stored = [
|
||||
"ai.use",
|
||||
"ai.manage_providers",
|
||||
"ai.apply_proposals",
|
||||
"automations.run",
|
||||
"automations.view",
|
||||
"tailscale.devices.view",
|
||||
"snippets.view",
|
||||
"snippets.share",
|
||||
];
|
||||
|
||||
expect(stored.filter((entry) => !isValidPermission(entry))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("role defaults apply exactly once", () => {
|
||||
let fixture: Fixture;
|
||||
let loader: PluginLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
state.roles.set("user", { id: 2, permissions: ["hosts.*"] });
|
||||
state.roles.set("admin", { id: 1, permissions: ["*"] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await loader?.shutdown();
|
||||
fixture?.cleanup();
|
||||
});
|
||||
|
||||
async function registerOnce() {
|
||||
const { resetPluginRuntime, getPluginRuntime } =
|
||||
await import("../../plugins/index.js");
|
||||
resetPluginRuntime();
|
||||
loader = getPluginRuntime().loader;
|
||||
const plugin = await loader.load(fixture.dir);
|
||||
const { activatePlugin } = await import("../../plugins/index.js");
|
||||
await activatePlugin(plugin.id);
|
||||
}
|
||||
|
||||
it("adds a declared default to the named role", async () => {
|
||||
fixture = createFixturePlugin({
|
||||
id: "docker",
|
||||
capabilities: ["kv:own"],
|
||||
manifestOverrides: {
|
||||
category: "Infrastructure",
|
||||
contributes: { permissions: [permission("run", ["user"])] },
|
||||
},
|
||||
});
|
||||
|
||||
await registerOnce();
|
||||
|
||||
expect(state.roles.get("user")?.permissions).toContain("docker.run");
|
||||
expect(state.appliedDefaults.has("user:docker.run")).toBe(true);
|
||||
});
|
||||
|
||||
// The whole point of the ledger: an admin's revoke is a decision, not a gap.
|
||||
it("does not re-add a default an admin removed", async () => {
|
||||
fixture = createFixturePlugin({
|
||||
id: "docker",
|
||||
capabilities: ["kv:own"],
|
||||
manifestOverrides: {
|
||||
category: "Infrastructure",
|
||||
contributes: { permissions: [permission("run", ["user"])] },
|
||||
},
|
||||
});
|
||||
|
||||
await registerOnce();
|
||||
expect(state.roles.get("user")?.permissions).toContain("docker.run");
|
||||
|
||||
// The admin revokes it.
|
||||
state.roles.set("user", { id: 2, permissions: ["hosts.*"] });
|
||||
await loader.shutdown();
|
||||
|
||||
await registerOnce();
|
||||
|
||||
expect(state.roles.get("user")?.permissions).not.toContain("docker.run");
|
||||
});
|
||||
|
||||
it("leaves a role alone when the plugin declares no defaults", async () => {
|
||||
fixture = createFixturePlugin({
|
||||
id: "docker",
|
||||
capabilities: ["kv:own"],
|
||||
manifestOverrides: {
|
||||
category: "Infrastructure",
|
||||
contributes: { permissions: [permission("run")] },
|
||||
},
|
||||
});
|
||||
|
||||
await registerOnce();
|
||||
|
||||
expect(state.roles.get("user")?.permissions).toEqual(["hosts.*"]);
|
||||
expect(isValidPermission("docker.run")).toBe(true);
|
||||
});
|
||||
|
||||
it("records the qualified id, not the short name", async () => {
|
||||
fixture = createFixturePlugin({
|
||||
id: "docker",
|
||||
capabilities: ["kv:own"],
|
||||
manifestOverrides: {
|
||||
category: "Infrastructure",
|
||||
contributes: { permissions: [permission("containers.run", ["user"])] },
|
||||
},
|
||||
});
|
||||
|
||||
await registerOnce();
|
||||
|
||||
expect(state.roles.get("user")?.permissions).toContain(
|
||||
"docker.containers.run",
|
||||
);
|
||||
expect(state.known.has("docker.containers.run")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Plugin-owned data against a real engine.
|
||||
*
|
||||
* The DDL snapshots in table-ddl.test.ts prove what the emitter writes; this
|
||||
* proves an engine accepts it, that adoption moves real rows, and that
|
||||
* removing a plugin's data removes exactly its own.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
defineTable,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
import { createTableSql, dropTableSql } from "../../plugins/table-builder.js";
|
||||
import {
|
||||
adoptLegacyTableSql,
|
||||
applyPluginMigrations,
|
||||
checksum,
|
||||
PluginMigrationChecksumError,
|
||||
tableExistsSql,
|
||||
type MigrationRunner,
|
||||
} from "../../plugins/migrations.js";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
let sqlite: Database.Database;
|
||||
let db: ReturnType<typeof drizzle>;
|
||||
let root: string;
|
||||
|
||||
const notes = defineTable(
|
||||
"note",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
title: varchar(200).notNull(),
|
||||
body: text(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{ indexes: [{ name: "idx_note_user", columns: ["userId"] }] },
|
||||
);
|
||||
|
||||
/** The ledger, kept in the same database so a rollback would take it too. */
|
||||
function createRunner(): MigrationRunner {
|
||||
return {
|
||||
dialect: "sqlite",
|
||||
listApplied: async (pluginId) =>
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT migration_id as migrationId, checksum FROM plugin_migrations WHERE plugin_id = ?",
|
||||
)
|
||||
.all(pluginId) as { migrationId: string; checksum: string }[],
|
||||
execute: async (statements) => {
|
||||
for (const statement of statements) sqlite.exec(statement);
|
||||
},
|
||||
record: async (pluginId, migration) => {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO plugin_migrations (plugin_id, migration_id, checksum, applied_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
pluginId,
|
||||
migration.id,
|
||||
migration.checksum,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeMigration(dialect: string, file: string, body: string): void {
|
||||
const dir = path.join(root, "migrations", dialect);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), body);
|
||||
}
|
||||
|
||||
function tableExists(name: string): boolean {
|
||||
return (
|
||||
(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
)
|
||||
.all(name) as unknown[]
|
||||
).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugin-data-"));
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL);
|
||||
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);
|
||||
CREATE TABLE plugin_migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
migration_id TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL,
|
||||
UNIQUE (plugin_id, migration_id)
|
||||
);
|
||||
`);
|
||||
sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run("u1", "ada");
|
||||
db = drizzle(sqlite);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sqlite.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("emitted DDL against a real engine", () => {
|
||||
it("creates the table and its indexes", () => {
|
||||
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
|
||||
expect(tableExists("p_demo_note")).toBe(true);
|
||||
});
|
||||
|
||||
it("enforces the foreign key to users", () => {
|
||||
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
sqlite
|
||||
.prepare("INSERT INTO p_demo_note (user_id, title) VALUES (?, ?)")
|
||||
.run("nobody", "orphan"),
|
||||
).toThrow(/FOREIGN KEY/);
|
||||
});
|
||||
|
||||
it("cascades a delete from the referenced user", () => {
|
||||
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
sqlite
|
||||
.prepare("INSERT INTO p_demo_note (user_id, title) VALUES (?, ?)")
|
||||
.run("u1", "mine");
|
||||
|
||||
sqlite.prepare("DELETE FROM users WHERE id = ?").run("u1");
|
||||
|
||||
const rows = sqlite.prepare("SELECT * FROM p_demo_note").all();
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fills the timestamp default the emitter wrote", () => {
|
||||
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
sqlite
|
||||
.prepare("INSERT INTO p_demo_note (user_id, title) VALUES (?, ?)")
|
||||
.run("u1", "mine");
|
||||
|
||||
const [row] = sqlite
|
||||
.prepare("SELECT created_at FROM p_demo_note")
|
||||
.all() as {
|
||||
created_at: string;
|
||||
}[];
|
||||
expect(row.created_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it("answers the table-exists probe for the active dialect", async () => {
|
||||
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
|
||||
const found = await db.all(tableExistsSql("sqlite", "p_demo_note"));
|
||||
const missing = await db.all(tableExistsSql("sqlite", "p_demo_nothing"));
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applying migrations for real", () => {
|
||||
it("creates the tables and records the ledger", async () => {
|
||||
writeMigration(
|
||||
"sqlite",
|
||||
"0001_init.sql",
|
||||
createTableSql("sqlite", "demo", notes).join("\n"),
|
||||
);
|
||||
|
||||
const applied = await applyPluginMigrations("demo", root, createRunner());
|
||||
|
||||
expect(applied).toEqual(["0001_init"]);
|
||||
expect(tableExists("p_demo_note")).toBe(true);
|
||||
expect(
|
||||
sqlite.prepare("SELECT * FROM plugin_migrations").all(),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is a no-op the second time, so a restart does not re-run DDL", async () => {
|
||||
writeMigration(
|
||||
"sqlite",
|
||||
"0001_init.sql",
|
||||
createTableSql("sqlite", "demo", notes).join("\n"),
|
||||
);
|
||||
await applyPluginMigrations("demo", root, createRunner());
|
||||
|
||||
const again = await applyPluginMigrations("demo", root, createRunner());
|
||||
|
||||
expect(again).toEqual([]);
|
||||
});
|
||||
|
||||
it("blocks the plugin when an applied migration was edited", async () => {
|
||||
writeMigration(
|
||||
"sqlite",
|
||||
"0001_init.sql",
|
||||
createTableSql("sqlite", "demo", notes).join("\n"),
|
||||
);
|
||||
await applyPluginMigrations("demo", root, createRunner());
|
||||
|
||||
writeMigration(
|
||||
"sqlite",
|
||||
"0001_init.sql",
|
||||
`${createTableSql("sqlite", "demo", notes).join("\n")}\n-- edited`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
applyPluginMigrations("demo", root, createRunner()),
|
||||
).rejects.toThrow(PluginMigrationChecksumError);
|
||||
});
|
||||
|
||||
it("leaves one plugin's failure without touching another's tables", async () => {
|
||||
writeMigration(
|
||||
"sqlite",
|
||||
"0001_init.sql",
|
||||
createTableSql("sqlite", "good", notes).join("\n"),
|
||||
);
|
||||
await applyPluginMigrations("good", root, createRunner());
|
||||
|
||||
const brokenRoot = fs.mkdtempSync(path.join(os.tmpdir(), "termix-broken-"));
|
||||
fs.mkdirSync(path.join(brokenRoot, "migrations", "sqlite"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(brokenRoot, "migrations", "sqlite", "0001_init.sql"),
|
||||
"CREATE TABLE p_broken_x (id integer); THIS IS NOT SQL;",
|
||||
);
|
||||
|
||||
await expect(
|
||||
applyPluginMigrations("broken", brokenRoot, createRunner()),
|
||||
).rejects.toThrow();
|
||||
|
||||
// The good plugin is untouched, and core is still usable.
|
||||
expect(tableExists("p_good_note")).toBe(true);
|
||||
expect(sqlite.prepare("SELECT * FROM users").all()).toHaveLength(1);
|
||||
|
||||
fs.rmSync(brokenRoot, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("adopting a legacy core table", () => {
|
||||
it("renames the table and keeps every row", async () => {
|
||||
sqlite.exec(
|
||||
"CREATE TABLE fleets (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)",
|
||||
);
|
||||
sqlite.prepare("INSERT INTO fleets (name) VALUES (?)").run("production");
|
||||
sqlite.prepare("INSERT INTO fleets (name) VALUES (?)").run("staging");
|
||||
|
||||
writeMigration(
|
||||
"sqlite",
|
||||
"0001_adopt.sql",
|
||||
adoptLegacyTableSql("sqlite", "fleets", "p_fleets_fleets"),
|
||||
);
|
||||
await applyPluginMigrations("fleets", root, createRunner(), {
|
||||
bundled: true,
|
||||
});
|
||||
|
||||
expect(tableExists("fleets")).toBe(false);
|
||||
const rows = sqlite
|
||||
.prepare("SELECT name FROM p_fleets_fleets ORDER BY id")
|
||||
.all() as { name: string }[];
|
||||
expect(rows.map((row) => row.name)).toEqual(["production", "staging"]);
|
||||
});
|
||||
|
||||
it("creates the table fresh when there is no legacy one", async () => {
|
||||
const fresh = defineTable("fleets", { id: id(), name: varchar(100) });
|
||||
writeMigration(
|
||||
"sqlite",
|
||||
"0001_adopt.sql",
|
||||
createTableSql("sqlite", "fleets", fresh).join("\n"),
|
||||
);
|
||||
|
||||
await applyPluginMigrations("fleets", root, createRunner());
|
||||
|
||||
expect(tableExists("p_fleets_fleets")).toBe(true);
|
||||
expect(sqlite.prepare("SELECT * FROM p_fleets_fleets").all()).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a plugin adopting a table that belongs to another", async () => {
|
||||
sqlite.exec("CREATE TABLE fleets (id INTEGER PRIMARY KEY)");
|
||||
writeMigration(
|
||||
"sqlite",
|
||||
"0001_steal.sql",
|
||||
adoptLegacyTableSql("sqlite", "fleets", "p_workspaces_fleets"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
applyPluginMigrations("workspaces", root, createRunner(), {
|
||||
bundled: true,
|
||||
}),
|
||||
).rejects.toThrow(/belongs to the "fleets" plugin/);
|
||||
|
||||
// Refused before execution, so the legacy table is still there.
|
||||
expect(tableExists("fleets")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removing a plugin's data", () => {
|
||||
it("drops the plugin's tables and leaves everything else", async () => {
|
||||
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
const other = defineTable("thing", { id: id(), name: varchar(50) });
|
||||
for (const statement of createTableSql("sqlite", "other", other)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
|
||||
sqlite.exec(dropTableSql("sqlite", "demo", notes));
|
||||
|
||||
expect(tableExists("p_demo_note")).toBe(false);
|
||||
expect(tableExists("p_other_thing")).toBe(true);
|
||||
expect(tableExists("users")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears only the removed plugin's ledger rows", () => {
|
||||
for (const pluginId of ["demo", "other"]) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO plugin_migrations (plugin_id, migration_id, checksum, applied_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run(pluginId, "0001_init", checksum("x"), new Date().toISOString());
|
||||
}
|
||||
|
||||
sqlite
|
||||
.prepare("DELETE FROM plugin_migrations WHERE plugin_id = ?")
|
||||
.run("demo");
|
||||
|
||||
const remaining = sqlite
|
||||
.prepare("SELECT plugin_id FROM plugin_migrations")
|
||||
.all() as { plugin_id: string }[];
|
||||
expect(remaining.map((row) => row.plugin_id)).toEqual(["other"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("querying through the built table", () => {
|
||||
it("reads back a row written with raw SQL", async () => {
|
||||
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
sqlite
|
||||
.prepare("INSERT INTO p_demo_note (user_id, title) VALUES (?, ?)")
|
||||
.run("u1", "written");
|
||||
|
||||
const rows = (await db.all(sql.raw("SELECT title FROM p_demo_note"))) as {
|
||||
title: string;
|
||||
}[];
|
||||
|
||||
expect(rows[0].title).toBe("written");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* A fixture plugin declares a protocol core has never heard of ("spice") and
|
||||
* gets a per-host login stored, encrypted, shared with a recipient (and
|
||||
* overridden by them) and resolved, with no core change: only the manifest
|
||||
* declaration. Runs on a real database with real repositories, sharing
|
||||
* manager and override service; only keys, permissions and audit are faked.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { TestSqliteDatabase } from "../database/repositories/test-support.js";
|
||||
import type { DatabaseContext } from "../../database/repositories/database-context.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
db: null as null | { drizzle: unknown },
|
||||
keys: new Map<string, Buffer>(),
|
||||
levels: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => state.db!.drizzle,
|
||||
getSqlite: () => null,
|
||||
}));
|
||||
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
triggerSave: vi.fn(),
|
||||
forceSave: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => {}),
|
||||
getAuditUsername: async (id: string) => id,
|
||||
getRequestMeta: () => ({}),
|
||||
}));
|
||||
vi.mock("../../utils/data-crypto.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../utils/data-crypto.js")>();
|
||||
const DataCrypto = actual.DataCrypto as unknown as Record<string, unknown>;
|
||||
DataCrypto.getUserDataKey = (userId: string) =>
|
||||
state.keys.get(userId) ?? null;
|
||||
DataCrypto.validateUserAccess = (userId: string) => {
|
||||
const key = state.keys.get(userId);
|
||||
if (!key) throw new Error(`User ${userId} has no data encryption key`);
|
||||
return key;
|
||||
};
|
||||
return actual;
|
||||
});
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async (userId: string, _hostId: number) => {
|
||||
const level = state.levels.get(userId);
|
||||
return level
|
||||
? { hasAccess: true, isShared: true, permissionLevel: level }
|
||||
: { hasAccess: false };
|
||||
},
|
||||
hasPermission: async () => true,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
loadProtocolAuthSummaries,
|
||||
ProtocolAuthWriteError,
|
||||
readProtocolAuthPayload,
|
||||
writeProtocolAuth,
|
||||
} from "../../hosts/protocol-auth/protocol-auth.js";
|
||||
import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js";
|
||||
import { SharedHostAuthOverrideService } from "../../utils/shared-host-auth-override-service.js";
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import { sanitizeHostForRecipient } from "../../database/routes/host-normalizers.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
|
||||
const HOST_ID = 10;
|
||||
const GRANT_ID = 1;
|
||||
|
||||
const manifest = {
|
||||
id: "fixture-spice",
|
||||
name: "Fixture Spice",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: ["credentials:read"],
|
||||
contributes: {
|
||||
protocols: [
|
||||
{
|
||||
id: "spice",
|
||||
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as PluginManifest;
|
||||
|
||||
let database: TestSqliteDatabase | null = null;
|
||||
let context: DatabaseContext;
|
||||
|
||||
async function exec(query: string): Promise<void> {
|
||||
await context.drizzle.run(sql.raw(query));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
database = new TestSqliteDatabase("sqlite");
|
||||
context = await database.connect();
|
||||
state.db = context;
|
||||
state.keys = new Map([
|
||||
["owner", crypto.randomBytes(32)],
|
||||
["recipient", crypto.randomBytes(32)],
|
||||
]);
|
||||
state.levels = new Map([["recipient", "connect"]]);
|
||||
for (const statement of [
|
||||
`INSERT INTO users (id, username, password_hash) VALUES
|
||||
('owner', 'owner', 'x'), ('recipient', 'recipient', 'x')`,
|
||||
`INSERT INTO ssh_credentials (id, user_id, name, username, password, auth_type)
|
||||
VALUES (5, 'recipient', 'mine', 'own-user', 'own-pass', 'password'),
|
||||
(6, 'owner', 'owners', 'cred-user', 'cred-pass', 'password')`,
|
||||
`INSERT INTO ssh_data (id, user_id, name, ip, port, username, password, auth_type)
|
||||
VALUES (${HOST_ID}, 'owner', 'desk', '10.0.0.10', 22, 'root', 'ssh-pass', 'password')`,
|
||||
`INSERT INTO host_access (id, host_id, user_id, granted_by, permission_level)
|
||||
VALUES (${GRANT_ID}, ${HOST_ID}, 'recipient', 'owner', 'connect')`,
|
||||
`INSERT INTO plugins (id, name, version, state, manifest_json)
|
||||
VALUES ('fixture-spice', 'Fixture Spice', '1.0.0', 'enabled', '{}')`,
|
||||
`INSERT INTO plugin_permission_grants (plugin_id, capability, source)
|
||||
VALUES ('fixture-spice', 'credentials:read', 'bundled')`,
|
||||
]) {
|
||||
await exec(statement);
|
||||
}
|
||||
invalidatePluginPermissionCache();
|
||||
// What the loader does with every installed manifest.
|
||||
setHostProtocolSource(() =>
|
||||
manifest.contributes!.protocols!.map((protocol) => ({
|
||||
...protocol,
|
||||
pluginId: manifest.id,
|
||||
pluginName: manifest.name,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
setHostProtocolSource(() => []);
|
||||
await database?.close();
|
||||
database = null;
|
||||
});
|
||||
|
||||
function pluginCtx() {
|
||||
return createPluginContext(
|
||||
manifest,
|
||||
createPluginHandle(manifest.id, { activate: () => {} }),
|
||||
);
|
||||
}
|
||||
|
||||
async function storeLogin(): Promise<void> {
|
||||
const patch = readProtocolAuthPayload({
|
||||
protocolAuth: {
|
||||
spice: {
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
password: "spice-pass",
|
||||
fields: { display: "2", ticket: "t-secret" },
|
||||
},
|
||||
unknown: { username: "ignored" },
|
||||
},
|
||||
});
|
||||
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
|
||||
}
|
||||
|
||||
async function storedRow(): Promise<Record<string, unknown>> {
|
||||
const rows = (await context.drizzle.all(
|
||||
sql.raw(`SELECT * FROM host_protocol_auth WHERE host_id = ${HOST_ID}`),
|
||||
)) as Record<string, unknown>[];
|
||||
expect(rows).toHaveLength(1);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
describe("a plugin protocol core does not know", () => {
|
||||
it("stores the login encrypted with the owner's key, secrets apart", async () => {
|
||||
await storeLogin();
|
||||
const row = await storedRow();
|
||||
|
||||
expect(row.protocol).toBe("spice");
|
||||
expect(row.username).toBe("viewer");
|
||||
expect(JSON.parse(row.fields as string)).toEqual({ display: "2" });
|
||||
expect(String(row.password)).not.toContain("spice-pass");
|
||||
expect(String(row.secret_fields)).not.toContain("t-secret");
|
||||
expect(
|
||||
FieldCrypto.decryptField(
|
||||
row.password as string,
|
||||
state.keys.get("owner")!,
|
||||
"",
|
||||
"password",
|
||||
),
|
||||
).toBe("spice-pass");
|
||||
expect(() =>
|
||||
FieldCrypto.decryptField(
|
||||
row.password as string,
|
||||
state.keys.get("recipient")!,
|
||||
"",
|
||||
"password",
|
||||
),
|
||||
).toThrow();
|
||||
|
||||
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
|
||||
expect(summaries.get(HOST_ID)).toEqual({
|
||||
spice: {
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
fields: { display: "2" },
|
||||
hasPassword: true,
|
||||
secretFieldKeys: ["ticket"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the owner's login for the declaring plugin", async () => {
|
||||
await storeLogin();
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.shared).toBe(false);
|
||||
expect(target?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
password: "spice-pass",
|
||||
fields: { display: "2", ticket: "t-secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a tampered password as missing, never as a value", async () => {
|
||||
await storeLogin();
|
||||
const row = await storedRow();
|
||||
const envelope = JSON.parse(row.password as string);
|
||||
envelope.tag = "0".repeat(envelope.tag.length);
|
||||
await exec(
|
||||
`UPDATE host_protocol_auth SET password = '${JSON.stringify(envelope)}' WHERE host_id = ${HOST_ID}`,
|
||||
);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.auth.password).toBe("");
|
||||
expect(target?.auth.fields.ticket).toBe("t-secret");
|
||||
});
|
||||
|
||||
it("gives a recipient the shared snapshot, not the owner's secret", async () => {
|
||||
await storeLogin();
|
||||
await SharedHostSecretsManager.getInstance().snapshotForUser(
|
||||
GRANT_ID,
|
||||
HOST_ID,
|
||||
"recipient",
|
||||
"owner",
|
||||
);
|
||||
// The owner's row loses its password without a resync: the recipient
|
||||
// keeps reading their own copy, never the owner's row.
|
||||
await exec(
|
||||
`UPDATE host_protocol_auth SET password = NULL WHERE host_id = ${HOST_ID}`,
|
||||
);
|
||||
|
||||
const target = await runAsActor("recipient", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.shared).toBe(true);
|
||||
expect(target?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
password: "spice-pass",
|
||||
fields: { display: "2", ticket: "t-secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the recipient's own override over the shared snapshot", async () => {
|
||||
await storeLogin();
|
||||
await SharedHostSecretsManager.getInstance().snapshotForUser(
|
||||
GRANT_ID,
|
||||
HOST_ID,
|
||||
"recipient",
|
||||
"owner",
|
||||
);
|
||||
await SharedHostAuthOverrideService.getInstance().setCredentialId(
|
||||
HOST_ID,
|
||||
"recipient",
|
||||
"spice",
|
||||
5,
|
||||
);
|
||||
|
||||
const target = await runAsActor("recipient", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.auth).toEqual({
|
||||
authType: "credential",
|
||||
username: "own-user",
|
||||
password: "own-pass",
|
||||
// The owner's plain fields, never their secret ones.
|
||||
fields: { display: "2", ticket: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an override for a protocol nobody declares", async () => {
|
||||
await expect(
|
||||
SharedHostAuthOverrideService.getInstance().setCredentialId(
|
||||
HOST_ID,
|
||||
"recipient",
|
||||
"made-up",
|
||||
5,
|
||||
),
|
||||
).rejects.toThrow(/No plugin declares/);
|
||||
});
|
||||
|
||||
it("follows a credential the owner points the login at", async () => {
|
||||
const patch = readProtocolAuthPayload({
|
||||
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
|
||||
});
|
||||
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.auth).toMatchObject({
|
||||
authType: "credential",
|
||||
username: "cred-user",
|
||||
password: "cred-pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a shared editor change the login but not its credential", async () => {
|
||||
await storeLogin();
|
||||
const rename = readProtocolAuthPayload({
|
||||
protocolAuth: { spice: { username: "renamed", password: "" } },
|
||||
});
|
||||
await writeProtocolAuth("owner", HOST_ID, rename!, { isOwner: false });
|
||||
expect((await storedRow()).username).toBe("renamed");
|
||||
// An empty password from an editor who cannot see it keeps it.
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
|
||||
);
|
||||
expect(target?.auth.password).toBe("spice-pass");
|
||||
|
||||
const repoint = readProtocolAuthPayload({
|
||||
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
|
||||
});
|
||||
await expect(
|
||||
writeProtocolAuth("owner", HOST_ID, repoint!, { isOwner: false }),
|
||||
).rejects.toBeInstanceOf(ProtocolAuthWriteError);
|
||||
const remove = readProtocolAuthPayload({ protocolAuth: { spice: null } });
|
||||
await writeProtocolAuth("owner", HOST_ID, remove!, { isOwner: false });
|
||||
expect(
|
||||
(await context.drizzle.all(
|
||||
sql.raw(`SELECT id FROM host_protocol_auth`),
|
||||
)) as unknown[],
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows each share level only what it may see", async () => {
|
||||
await storeLogin();
|
||||
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
|
||||
const host = {
|
||||
id: HOST_ID,
|
||||
name: "desk",
|
||||
ip: "10.0.0.10",
|
||||
protocolAuth: summaries.get(HOST_ID),
|
||||
};
|
||||
|
||||
expect(
|
||||
sanitizeHostForRecipient({ ...host }, "connect").protocolAuth,
|
||||
).toEqual({ spice: { authType: "direct" } });
|
||||
for (const level of ["view", "edit", "manage"]) {
|
||||
expect(
|
||||
sanitizeHostForRecipient({ ...host }, level).protocolAuth,
|
||||
level,
|
||||
).toEqual({
|
||||
spice: {
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
fields: { display: "2" },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearRegistry,
|
||||
consume,
|
||||
has,
|
||||
provide,
|
||||
revoke,
|
||||
} from "../../plugins/registry.js";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("service registry", () => {
|
||||
afterEach(() => clearRegistry());
|
||||
|
||||
it("round-trips a provider", () => {
|
||||
const pool = { name: "ssh-pool" };
|
||||
provide("ssh.transport", pool);
|
||||
|
||||
expect(has("ssh.transport")).toBe(true);
|
||||
expect(consume<typeof pool>("ssh.transport")).toBe(pool);
|
||||
});
|
||||
|
||||
it("returns undefined rather than throwing for a missing provider", () => {
|
||||
// Callers have to cope with a provider disappearing when its plugin is
|
||||
// disabled, so this is the contract rather than an error.
|
||||
expect(consume("nothing.here")).toBeUndefined();
|
||||
expect(has("nothing.here")).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces an existing provider", () => {
|
||||
provide("ssh.transport", { id: 1 });
|
||||
provide("ssh.transport", { id: 2 });
|
||||
expect(consume<{ id: number }>("ssh.transport")?.id).toBe(2);
|
||||
});
|
||||
|
||||
it("only revokes the value that is actually registered", () => {
|
||||
const first = { id: 1 };
|
||||
const second = { id: 2 };
|
||||
|
||||
provide("terminal.sessions", first);
|
||||
provide("terminal.sessions", second);
|
||||
|
||||
// A crashed-and-restarted plugin must not revoke the replacement its own
|
||||
// restart installed.
|
||||
expect(revoke("terminal.sessions", first)).toBe(false);
|
||||
expect(consume("terminal.sessions")).toBe(second);
|
||||
|
||||
expect(revoke("terminal.sessions", second)).toBe(true);
|
||||
expect(consume("terminal.sessions")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("revokes unconditionally when no value is given", () => {
|
||||
provide("terminal.sessions", { id: 1 });
|
||||
expect(revoke("terminal.sessions")).toBe(true);
|
||||
expect(has("terminal.sessions")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a revoke of something never registered", () => {
|
||||
expect(revoke("nothing.here")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Puts a 2.8 install on a real Postgres or MySQL server for the upgrade test:
|
||||
* wipes the database, applies release-2.8.0-tag's own drizzle migrations
|
||||
* (UPGRADE_LEGACY_MIGRATIONS_DIR, which scripts/upgrade-check.sh extracts
|
||||
* from git), then copies every row of the SQLite fixture across. After that
|
||||
* the database is exactly what a 2.8 server left behind, and the normal boot
|
||||
* takes it the rest of the way.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { sql, type SQL } from "drizzle-orm";
|
||||
import { ROWS } from "../fixtures/upgrade/rows.js";
|
||||
|
||||
export type RemoteDialect = "postgres" | "mysql";
|
||||
|
||||
type Remote = { execute: (query: SQL) => Promise<unknown> };
|
||||
|
||||
/** TEST_DIALECT, the same switch the repository tests use. */
|
||||
export function remoteTestDialect(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): RemoteDialect | null {
|
||||
const value = env.TEST_DIALECT?.trim().toLowerCase();
|
||||
return value === "postgres" || value === "mysql" ? value : null;
|
||||
}
|
||||
|
||||
async function rows<T>(db: Remote, query: SQL): Promise<T[]> {
|
||||
const result = (await db.execute(query)) as { rows?: T[] } | T[];
|
||||
if (Array.isArray(result)) {
|
||||
return (Array.isArray(result[0]) ? result[0] : result) as T[];
|
||||
}
|
||||
return result.rows ?? [];
|
||||
}
|
||||
|
||||
async function wipe(db: Remote, dialect: RemoteDialect): Promise<void> {
|
||||
if (dialect === "postgres") {
|
||||
await db.execute(sql.raw("DROP SCHEMA IF EXISTS drizzle CASCADE"));
|
||||
await db.execute(sql.raw("DROP SCHEMA public CASCADE"));
|
||||
await db.execute(sql.raw("CREATE SCHEMA public"));
|
||||
return;
|
||||
}
|
||||
const [{ name }] = await rows<{ name: string }>(
|
||||
db,
|
||||
sql.raw("SELECT DATABASE() AS name"),
|
||||
);
|
||||
await db.execute(sql.raw(`DROP DATABASE \`${name}\``));
|
||||
await db.execute(sql.raw(`CREATE DATABASE \`${name}\``));
|
||||
await db.execute(sql.raw(`USE \`${name}\``));
|
||||
}
|
||||
|
||||
async function columnTypes(
|
||||
db: Remote,
|
||||
dialect: RemoteDialect,
|
||||
table: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const schema =
|
||||
dialect === "postgres" ? sql`current_schema()` : sql`DATABASE()`;
|
||||
const found = await rows<{ name: string; type: string }>(
|
||||
db,
|
||||
sql`SELECT column_name AS name, data_type AS type FROM information_schema.columns WHERE table_schema = ${schema} AND table_name = ${table}`,
|
||||
);
|
||||
return new Map(found.map((column) => [column.name, column.type]));
|
||||
}
|
||||
|
||||
async function resyncSequence(db: Remote, table: string): Promise<void> {
|
||||
const [sequence] = await rows<{ name: string | null }>(
|
||||
db,
|
||||
sql.raw(
|
||||
`SELECT pg_get_serial_sequence('"${table}"', 'id') AS name FROM information_schema.columns ` +
|
||||
`WHERE table_schema = current_schema() AND table_name = '${table}' AND column_name = 'id'`,
|
||||
),
|
||||
);
|
||||
if (!sequence?.name) return;
|
||||
await db.execute(
|
||||
sql.raw(
|
||||
`SELECT setval('${sequence.name}', COALESCE((SELECT MAX(id) FROM "${table}"), 0) + 1, false)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function seedRemote28(
|
||||
dialect: RemoteDialect,
|
||||
url: string,
|
||||
fixture: Buffer,
|
||||
): Promise<void> {
|
||||
const legacyMigrations = process.env.UPGRADE_LEGACY_MIGRATIONS_DIR;
|
||||
if (!legacyMigrations) {
|
||||
throw new Error(
|
||||
"UPGRADE_LEGACY_MIGRATIONS_DIR must point at release-2.8.0-tag's drizzle folder. Run scripts/upgrade-check.sh.",
|
||||
);
|
||||
}
|
||||
|
||||
const { drizzle } =
|
||||
dialect === "postgres"
|
||||
? await import("drizzle-orm/node-postgres")
|
||||
: await import("drizzle-orm/mysql2");
|
||||
const db = (
|
||||
drizzle as unknown as (
|
||||
config: unknown,
|
||||
) => Remote & { $client: { end: () => Promise<void> } }
|
||||
)(
|
||||
dialect === "postgres"
|
||||
? { connection: url }
|
||||
: { connection: { uri: url, connectionLimit: 1 } },
|
||||
);
|
||||
const source = new Database(fixture, { readonly: true });
|
||||
|
||||
try {
|
||||
await wipe(db, dialect);
|
||||
|
||||
const { runRemoteMigrations } =
|
||||
await import("../../database/db/migrate.js");
|
||||
await runRemoteMigrations(dialect, db as never, {
|
||||
DRIZZLE_MIGRATIONS_DIR: legacyMigrations,
|
||||
});
|
||||
|
||||
for (const table of Object.keys(ROWS)) {
|
||||
const types = await columnTypes(db, dialect, table);
|
||||
if (types.size === 0) {
|
||||
throw new Error(`2.8's ${dialect} schema has no ${table} table`);
|
||||
}
|
||||
for (const row of source
|
||||
.prepare(`SELECT * FROM "${table}"`)
|
||||
.all() as Array<Record<string, unknown>>) {
|
||||
const columns = Object.keys(row).filter(
|
||||
(column) => types.has(column) && row[column] !== null,
|
||||
);
|
||||
const values = columns.map((column) =>
|
||||
types.get(column) === "boolean" ? Boolean(row[column]) : row[column],
|
||||
);
|
||||
await db.execute(
|
||||
sql`INSERT INTO ${sql.identifier(table)} (${sql.join(
|
||||
columns.map((column) => sql.identifier(column)),
|
||||
sql`, `,
|
||||
)}) VALUES (${sql.join(
|
||||
values.map((value) => sql`${value}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
if (dialect === "postgres") await resyncSequence(db, table);
|
||||
}
|
||||
} finally {
|
||||
source.close();
|
||||
await db.$client.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
FETCH_BLOCKED_PORTS,
|
||||
installSafePorts,
|
||||
} from "../../../../packages/plugin-sdk/src/testing/safe-ports.js";
|
||||
|
||||
let server: http.Server | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) =>
|
||||
server?.listening ? server.close(() => resolve()) : resolve(),
|
||||
);
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("installSafePorts", () => {
|
||||
it("listens again when the random port is one fetch refuses", async () => {
|
||||
installSafePorts();
|
||||
server = http.createServer((_req, res) => res.end("ok"));
|
||||
const realAddress = server.address.bind(server);
|
||||
vi.spyOn(server, "address").mockImplementationOnce(() => ({
|
||||
address: "127.0.0.1",
|
||||
family: "IPv4",
|
||||
port: 6000,
|
||||
}));
|
||||
const onListening = vi.fn();
|
||||
|
||||
await new Promise<void>((resolve) =>
|
||||
server!.listen(0, "127.0.0.1", () => {
|
||||
onListening();
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onListening).toHaveBeenCalledTimes(1);
|
||||
const { port } = realAddress() as AddressInfo;
|
||||
expect(FETCH_BLOCKED_PORTS.has(port)).toBe(false);
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`);
|
||||
expect(await response.text()).toBe("ok");
|
||||
});
|
||||
|
||||
it("leaves a fixed port alone", async () => {
|
||||
installSafePorts();
|
||||
server = http.createServer();
|
||||
await new Promise<void>((resolve) => server!.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
|
||||
server = http.createServer();
|
||||
await new Promise<void>((resolve) => server!.listen(port, resolve));
|
||||
expect((server.address() as AddressInfo).port).toBe(port);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { pluginLogger: log };
|
||||
});
|
||||
|
||||
const { createPluginSchedule } = await import("../../plugins/schedule.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ctx.schedule", () => {
|
||||
it("repeats a job and stops it", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const job = vi.fn();
|
||||
|
||||
const stop = schedule.every(1000, job);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(job).toHaveBeenCalledTimes(3);
|
||||
|
||||
stop();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(job).toHaveBeenCalledTimes(3);
|
||||
expect(bag.size).toBe(0);
|
||||
});
|
||||
|
||||
it("runs straight away when asked", async () => {
|
||||
const schedule = createPluginSchedule(
|
||||
new DisposableBag("fixture"),
|
||||
vi.fn(),
|
||||
);
|
||||
const job = vi.fn();
|
||||
schedule.every(1000, job, { runNow: true });
|
||||
expect(job).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never overlaps a slow run with the next tick", async () => {
|
||||
const schedule = createPluginSchedule(
|
||||
new DisposableBag("fixture"),
|
||||
vi.fn(),
|
||||
);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
schedule.every(100, async () => {
|
||||
running++;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
running--;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(peak).toBe(1);
|
||||
});
|
||||
|
||||
it("logs a failing job instead of throwing", async () => {
|
||||
const log = vi.fn();
|
||||
const schedule = createPluginSchedule(new DisposableBag("fixture"), log);
|
||||
schedule.every(100, () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(log).toHaveBeenCalledWith("Scheduled job failed", expect.any(Error));
|
||||
});
|
||||
|
||||
it("clears every timer when the plugin is disposed", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const repeating = vi.fn();
|
||||
const once = vi.fn();
|
||||
schedule.every(100, repeating, { jitterMs: 50 });
|
||||
schedule.after(500, once);
|
||||
|
||||
await bag.disposeAll();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(repeating).not.toHaveBeenCalled();
|
||||
expect(once).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs a one-off job once and can cancel it", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const once = vi.fn();
|
||||
schedule.after(100, once);
|
||||
const cancelled = vi.fn();
|
||||
const cancel = schedule.after(100, cancelled);
|
||||
cancel();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(once).toHaveBeenCalledOnce();
|
||||
expect(cancelled).not.toHaveBeenCalled();
|
||||
expect(bag.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The SDK's test helpers, against a fixture plugin in a temp directory.
|
||||
*
|
||||
* Plugin suites rely on createTestDb applying migrations exactly as the
|
||||
* server does and on createMockCtx enforcing permissions, so a regression in
|
||||
* either would pass every plugin's tests while proving nothing.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
import { adoptTableSql } from "@termix/plugin-sdk/ddl";
|
||||
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
|
||||
|
||||
let root: string;
|
||||
let pluginDir: string;
|
||||
|
||||
const notes = adoptLegacyTable(
|
||||
"fleets",
|
||||
defineTable("fleets", { id: id(), userId: refUser(), name: text() }),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sdk-testing-"));
|
||||
// The directory name is the plugin id, as it is in plugins/.
|
||||
pluginDir = path.join(root, "fleets");
|
||||
fs.mkdirSync(path.join(pluginDir, "migrations", "sqlite"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "migrations", "sqlite", "0001_adopt.sql"),
|
||||
adoptTableSql("sqlite", "fleets", notes).join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createTestDb", () => {
|
||||
it("applies the plugin's migrations after the before hook", async () => {
|
||||
const db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
sqlite.exec(
|
||||
"CREATE TABLE fleets (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, name TEXT)",
|
||||
);
|
||||
sqlite.exec("INSERT INTO fleets (user_id, name) VALUES ('u1', 'kept')");
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt"]);
|
||||
expect(db.sqlite.prepare("SELECT name FROM p_fleets_fleets").all()).toEqual(
|
||||
[{ name: "kept" }],
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("hands back a database a plugin can query through define and client", async () => {
|
||||
const db = await createTestDb(pluginDir);
|
||||
db.sqlite.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
|
||||
const table = (await db.database.define(notes)) as never;
|
||||
const drizzle = (await db.database.client()) as {
|
||||
insert: (t: never) => { values: (v: object) => Promise<unknown> };
|
||||
select: () => { from: (t: never) => Promise<unknown[]> };
|
||||
};
|
||||
await drizzle.insert(table).values({ userId: "u1", name: "n" });
|
||||
await db.database.persist();
|
||||
|
||||
expect(await drizzle.select().from(table)).toEqual([
|
||||
{ id: 1, userId: "u1", name: "n" },
|
||||
]);
|
||||
expect(db.persisted).toBe(1);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMockCtx permissions", () => {
|
||||
const respond = () => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.body = body;
|
||||
return body;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
};
|
||||
|
||||
it("passes everything when no permissions are given", async () => {
|
||||
const { ctx } = createMockCtx({ pluginId: "demo" });
|
||||
expect(await ctx.rbac.has("use")).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves short names and refuses what the user does not hold", async () => {
|
||||
const { ctx } = createMockCtx({
|
||||
pluginId: "demo",
|
||||
permissions: ["demo.use", "hosts.view"],
|
||||
});
|
||||
|
||||
expect(await ctx.rbac.has("use")).toBe(true);
|
||||
expect(await ctx.rbac.has("hosts.view")).toBe(true);
|
||||
expect(await ctx.rbac.has("manage")).toBe(false);
|
||||
|
||||
const res = respond();
|
||||
let passed = false;
|
||||
ctx.rbac.require("manage")({} as never, res as never, () => {
|
||||
passed = true;
|
||||
});
|
||||
expect(passed).toBe(false);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toMatchObject({ required: "demo.manage" });
|
||||
});
|
||||
|
||||
it("uses the router factory and follows setActor", () => {
|
||||
const router = {};
|
||||
const mock = createMockCtx({
|
||||
pluginId: "demo",
|
||||
capabilities: ["network:serve"],
|
||||
router: () => router,
|
||||
});
|
||||
expect(mock.ctx.http.router()).toBe(router);
|
||||
|
||||
mock.setActor("u9");
|
||||
expect(mock.ctx.currentActor()).toBe("u9");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Two real plugins on disk, one offering a secret and one borrowing it, driven
|
||||
* through the actual PluginLoader.
|
||||
*
|
||||
* The secret ("api-key") and the permission gating it
|
||||
* ("testplugin.secrets.share") are fixture-owned, so nothing about the
|
||||
* shipped plugins is under test even though the fixtures borrow their ids.
|
||||
*
|
||||
* What the unit test cannot show, and this does: that the manifest declaration
|
||||
* is enforced by the real ctx, and that a genuine uninstall through
|
||||
* loader.deactivate() leaves the borrower with null rather than a handle onto
|
||||
* a resolver whose plugin is gone.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Fixture } from "./fixture-plugin.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
/** Permissions the acting user currently holds. Mutated mid-test. */
|
||||
granted: new Set<string>(),
|
||||
auditEntries: [] as Record<string, unknown>[],
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async (params: Record<string, unknown>) => {
|
||||
state.auditEntries.push(params);
|
||||
}),
|
||||
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
state.granted.has(permission),
|
||||
invalidateUserPermissionCache: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||
const {
|
||||
clearSecretRegistry,
|
||||
getSecretRegistration,
|
||||
PluginSecretPermissionError,
|
||||
PluginSecretUndeclaredError,
|
||||
} = await import("../../plugins/secret-registry.js");
|
||||
const { pluginEvents } = await import("../../plugins/events.js");
|
||||
const { createFixturePlugin } = await import("./fixture-plugin.js");
|
||||
|
||||
const PROVIDER_ID = "ssh-terminal";
|
||||
const CONSUMER_ID = "docker";
|
||||
const KEY = "api-key";
|
||||
// Registered as <pluginId>.<name>, so it carries the provider fixture id.
|
||||
const PERMISSION = "ssh-terminal.secrets.share";
|
||||
const SECRET = "sk-fixture-value";
|
||||
|
||||
/**
|
||||
* The provider offers a resolver reading the current value from a file, so the
|
||||
* test can rotate or clear it from outside without restarting the plugin. A
|
||||
* module-level variable would not work: the plugin is imported by URL and
|
||||
* lives in its own ESM module instance.
|
||||
*/
|
||||
function providerSource(valueFile: string): string {
|
||||
const literal = JSON.stringify(valueFile);
|
||||
return `
|
||||
import fs from "node:fs";
|
||||
export async function activate(ctx) {
|
||||
ctx.secrets.offer(${JSON.stringify(KEY)}, async () => {
|
||||
if (!fs.existsSync(${literal})) return null;
|
||||
const value = fs.readFileSync(${literal}, "utf8");
|
||||
return value.length > 0 ? value : null;
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The consumer stashes a bound read on globalThis so the test can invoke it as
|
||||
* a specific user afterwards. Note it stashes a FUNCTION, not a value: there
|
||||
* is no handle to hold, which is the property under test.
|
||||
*/
|
||||
const CONSUMER_SOURCE = `
|
||||
export async function activate(ctx) {
|
||||
globalThis.__borrow = (userId) =>
|
||||
ctx.secrets.getShared(${JSON.stringify(PROVIDER_ID)}, ${JSON.stringify(KEY)}, { userId });
|
||||
globalThis.__borrowUndeclared = (userId) =>
|
||||
ctx.secrets.getShared(${JSON.stringify(PROVIDER_ID)}, "not-declared", { userId });
|
||||
}
|
||||
`;
|
||||
|
||||
function providerFixture(): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: PROVIDER_ID,
|
||||
capabilities: ["kv:own"],
|
||||
manifestOverrides: {
|
||||
category: "Terminal",
|
||||
providesSecret: [{ key: KEY, permission: PERMISSION }],
|
||||
contributes: {
|
||||
permissions: [
|
||||
{
|
||||
name: "secrets.share",
|
||||
titleKey: "permissions.secrets.share.title",
|
||||
descriptionKey: "permissions.secrets.share.description",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function consumerFixture(
|
||||
requiresSecret: Array<Record<string, unknown>> = [
|
||||
{ plugin: PROVIDER_ID, key: KEY, optional: true },
|
||||
],
|
||||
): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: CONSUMER_ID,
|
||||
capabilities: ["kv:own", "users:impersonate"],
|
||||
backendSource: CONSUMER_SOURCE,
|
||||
manifestOverrides: { category: "Infrastructure", requiresSecret },
|
||||
});
|
||||
}
|
||||
|
||||
function borrow(userId = "user-1"): Promise<string | null> {
|
||||
const fn = (globalThis as Record<string, unknown>).__borrow as (
|
||||
id: string,
|
||||
) => Promise<string | null>;
|
||||
return fn(userId);
|
||||
}
|
||||
|
||||
function borrowUndeclared(userId = "user-1"): Promise<string | null> {
|
||||
const fn = (globalThis as Record<string, unknown>).__borrowUndeclared as (
|
||||
id: string,
|
||||
) => Promise<string | null>;
|
||||
return fn(userId);
|
||||
}
|
||||
|
||||
describe("cross-plugin secret references", () => {
|
||||
let provider: Fixture | null = null;
|
||||
let consumer: Fixture | null = null;
|
||||
let loader: InstanceType<typeof PluginLoader> | null = null;
|
||||
let valueFile = "";
|
||||
|
||||
beforeEach(() => {
|
||||
state.granted = new Set([PERMISSION]);
|
||||
state.auditEntries = [];
|
||||
clearSecretRegistry();
|
||||
pluginEvents.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await loader?.shutdown();
|
||||
loader = null;
|
||||
provider?.cleanup();
|
||||
consumer?.cleanup();
|
||||
provider = null;
|
||||
consumer = null;
|
||||
delete (globalThis as Record<string, unknown>).__borrow;
|
||||
delete (globalThis as Record<string, unknown>).__borrowUndeclared;
|
||||
clearSecretRegistry();
|
||||
pluginEvents.clear();
|
||||
});
|
||||
|
||||
async function activateProvider() {
|
||||
provider = providerFixture();
|
||||
valueFile = path.join(provider.root, "secret.txt");
|
||||
fs.writeFileSync(valueFile, SECRET);
|
||||
fs.writeFileSync(
|
||||
path.join(provider.dir, "backend", "index.mjs"),
|
||||
providerSource(valueFile),
|
||||
);
|
||||
|
||||
loader ??= new PluginLoader();
|
||||
const plugin = await loader.load(provider.dir);
|
||||
await loader.activate(plugin.id);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async function activateConsumer(
|
||||
requiresSecret?: Array<Record<string, unknown>>,
|
||||
) {
|
||||
consumer = requiresSecret
|
||||
? consumerFixture(requiresSecret)
|
||||
: consumerFixture();
|
||||
|
||||
loader ??= new PluginLoader();
|
||||
const plugin = await loader.load(consumer.dir);
|
||||
await loader.activate(plugin.id);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async function activateBoth() {
|
||||
loader = new PluginLoader();
|
||||
const providerPlugin = await activateProvider();
|
||||
const consumerPlugin = await activateConsumer();
|
||||
return { providerPlugin, consumerPlugin };
|
||||
}
|
||||
|
||||
it("registers the offer when the provider activates", async () => {
|
||||
const { providerPlugin, consumerPlugin } = await activateBoth();
|
||||
|
||||
expect(providerPlugin.state).toBe("active");
|
||||
expect(consumerPlugin.state).toBe("active");
|
||||
|
||||
const registration = getSecretRegistration(PROVIDER_ID, KEY);
|
||||
expect(registration?.pluginId).toBe(PROVIDER_ID);
|
||||
expect(registration?.permission).toBe(PERMISSION);
|
||||
});
|
||||
|
||||
it("resolves for a user who holds the permission", async () => {
|
||||
await activateBoth();
|
||||
|
||||
await expect(borrow()).resolves.toBe(SECRET);
|
||||
});
|
||||
|
||||
it("denies a user without the permission, though the secret is set", async () => {
|
||||
await activateBoth();
|
||||
state.granted = new Set();
|
||||
|
||||
await expect(borrow("user-2")).rejects.toBeInstanceOf(
|
||||
PluginSecretPermissionError,
|
||||
);
|
||||
// Still installed, still set: the difference is purely this user's role.
|
||||
expect(getSecretRegistration(PROVIDER_ID, KEY)).toBeDefined();
|
||||
expect(fs.readFileSync(valueFile, "utf8")).toBe(SECRET);
|
||||
});
|
||||
|
||||
it("lets one user borrow while another is denied, with no reinstall", async () => {
|
||||
await activateBoth();
|
||||
|
||||
// The grant is per-user, so both answers come from the same live install.
|
||||
await expect(borrow("allowed-user")).resolves.toBe(SECRET);
|
||||
|
||||
state.granted = new Set();
|
||||
await expect(borrow("denied-user")).rejects.toBeInstanceOf(
|
||||
PluginSecretPermissionError,
|
||||
);
|
||||
|
||||
state.granted = new Set([PERMISSION]);
|
||||
await expect(borrow("allowed-user")).resolves.toBe(SECRET);
|
||||
});
|
||||
|
||||
it("returns null when the provider is installed but the key is not set", async () => {
|
||||
await activateBoth();
|
||||
fs.writeFileSync(valueFile, "");
|
||||
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the providing plugin was never installed", async () => {
|
||||
loader = new PluginLoader();
|
||||
await activateConsumer();
|
||||
|
||||
// The consumer activates regardless: the reference is resolved per call.
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("activates the consumer even when a non-optional reference is absent", async () => {
|
||||
loader = new PluginLoader();
|
||||
const plugin = await activateConsumer([{ plugin: PROVIDER_ID, key: KEY }]);
|
||||
|
||||
// Unlike a service requirement, an absent secret provider is recoverable
|
||||
// by installing it later, so it must not be a hard ordering dependency.
|
||||
expect(plugin.state).toBe("active");
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a read the consumer's manifest never declared", async () => {
|
||||
await activateBoth();
|
||||
|
||||
await expect(borrowUndeclared()).rejects.toBeInstanceOf(
|
||||
PluginSecretUndeclaredError,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to offer a secret the provider's manifest never declared", async () => {
|
||||
provider = createFixturePlugin({
|
||||
id: PROVIDER_ID,
|
||||
capabilities: ["kv:own"],
|
||||
backendSource: `
|
||||
export async function activate(ctx) {
|
||||
ctx.secrets.offer("undeclared-key", () => "nope");
|
||||
}
|
||||
`,
|
||||
manifestOverrides: {
|
||||
category: "Terminal",
|
||||
providesSecret: [{ key: KEY, permission: PERMISSION }],
|
||||
contributes: {
|
||||
permissions: [
|
||||
{
|
||||
name: "secrets.share",
|
||||
titleKey: "permissions.secrets.share.title",
|
||||
descriptionKey: "permissions.secrets.share.description",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
loader = new PluginLoader();
|
||||
const plugin = await loader.load(provider.dir);
|
||||
await loader.activate(plugin.id).catch(() => undefined);
|
||||
|
||||
expect(plugin.state).not.toBe("active");
|
||||
expect(
|
||||
getSecretRegistration(PROVIDER_ID, "undeclared-key"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("the provider stays in control", () => {
|
||||
it("serves a rotated value on the next borrow", async () => {
|
||||
await activateBoth();
|
||||
await expect(borrow()).resolves.toBe(SECRET);
|
||||
|
||||
fs.writeFileSync(valueFile, "sk-fixture-rotated");
|
||||
|
||||
// Nothing to invalidate: the borrower never held a copy to go stale.
|
||||
await expect(borrow()).resolves.toBe("sk-fixture-rotated");
|
||||
});
|
||||
|
||||
it("nulls the borrow out when the provider clears the secret", async () => {
|
||||
await activateBoth();
|
||||
await expect(borrow()).resolves.toBe(SECRET);
|
||||
|
||||
fs.rmSync(valueFile);
|
||||
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstalling the provider", () => {
|
||||
it("nulls out the reference rather than leaving a dangling handle", async () => {
|
||||
const { providerPlugin } = await activateBoth();
|
||||
await expect(borrow()).resolves.toBe(SECRET);
|
||||
|
||||
await loader!.deactivate(providerPlugin.id);
|
||||
|
||||
// The borrower is untouched: still active, still permitted, still
|
||||
// declaring the reference. It just gets null now, and does not throw.
|
||||
expect(getSecretRegistration(PROVIDER_ID, KEY)).toBeUndefined();
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps serving nothing even though the underlying value still exists", async () => {
|
||||
const { providerPlugin } = await activateBoth();
|
||||
await loader!.deactivate(providerPlugin.id);
|
||||
|
||||
// The file is still on disk. Access went away with the plugin that
|
||||
// mediated it, which is the point: there was never a copy to fall back on.
|
||||
expect(fs.readFileSync(valueFile, "utf8")).toBe(SECRET);
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("resumes when the provider is reinstalled, with no borrower restart", async () => {
|
||||
const { providerPlugin } = await activateBoth();
|
||||
await loader!.deactivate(providerPlugin.id);
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
|
||||
await loader!.activate(providerPlugin.id);
|
||||
|
||||
await expect(borrow()).resolves.toBe(SECRET);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit", () => {
|
||||
it("records a borrow without the value", async () => {
|
||||
await activateBoth();
|
||||
await borrow("user-9");
|
||||
|
||||
const entry = state.auditEntries.at(-1);
|
||||
expect(entry).toMatchObject({
|
||||
userId: "user-9",
|
||||
username: `plugin:${CONSUMER_ID}`,
|
||||
action: "plugin_secret_shared_read",
|
||||
resourceId: PROVIDER_ID,
|
||||
success: true,
|
||||
});
|
||||
expect(JSON.stringify(state.auditEntries)).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it("records a denial without the value", async () => {
|
||||
await activateBoth();
|
||||
state.granted = new Set();
|
||||
await borrow().catch(() => undefined);
|
||||
|
||||
expect(state.auditEntries.at(-1)).toMatchObject({ success: false });
|
||||
expect(JSON.stringify(state.auditEntries)).not.toContain(SECRET);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Cross-plugin secret references: the permission gate, the two null cases, and
|
||||
* what an uninstall does to a borrower still pointing at the provider.
|
||||
*
|
||||
* The distinction under test throughout is between a denial (an error, because
|
||||
* the caller asked for something it may not have) and an absence (null, because
|
||||
* the thing legitimately is not there). A consumer that cannot tell those apart
|
||||
* would read "you may not" as "not configured" and fall back to something
|
||||
* weaker, so every case below asserts which of the two it got.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
clearSecretRegistry,
|
||||
getSecretRegistration,
|
||||
listSecrets,
|
||||
offerSecret,
|
||||
readSharedSecret,
|
||||
resolveSecretRequirements,
|
||||
withdrawAllForPlugin,
|
||||
withdrawSecret,
|
||||
PluginSecretActorError,
|
||||
PluginSecretPermissionError,
|
||||
PluginSecretUndeclaredError,
|
||||
} = await import("../../plugins/secret-registry.js");
|
||||
|
||||
const PROVIDER = "ai-assistant";
|
||||
const KEY = "api-key";
|
||||
const PERMISSION = "ai.secrets.share";
|
||||
const SECRET = "sk-live-value";
|
||||
|
||||
/** Permissions the acting user currently holds. Mutated mid-test. */
|
||||
let granted = new Set<string>();
|
||||
let auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
function offer(overrides: Partial<Parameters<typeof offerSecret>[0]> = {}) {
|
||||
return offerSecret({
|
||||
pluginId: PROVIDER,
|
||||
pluginName: "AI Assistant",
|
||||
key: KEY,
|
||||
permission: PERMISSION,
|
||||
resolve: () => SECRET,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/** A consumer manifest declaring the reference, as the real one would. */
|
||||
function consumer(
|
||||
requiresSecret: Array<{ plugin: string; key: string; optional?: boolean }> = [
|
||||
{ plugin: PROVIDER, key: KEY, optional: true },
|
||||
],
|
||||
) {
|
||||
return { id: "reporting-plugin", requiresSecret } as never;
|
||||
}
|
||||
|
||||
function context(userId: string | undefined) {
|
||||
return {
|
||||
resolveUserId: () => userId,
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
granted.has(permission),
|
||||
audit: (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function read(
|
||||
manifest = consumer(),
|
||||
userId: string | undefined = "user-1",
|
||||
providerPluginId = PROVIDER,
|
||||
key = KEY,
|
||||
) {
|
||||
return readSharedSecret(manifest, providerPluginId, key, context(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* A read with no acting user. Separate from read() on purpose: a default
|
||||
* parameter cannot express this, since passing undefined explicitly would
|
||||
* just fall back to the default and quietly test the wrong thing.
|
||||
*/
|
||||
function readAnonymously(manifest = consumer()) {
|
||||
return readSharedSecret(manifest, PROVIDER, KEY, context(undefined));
|
||||
}
|
||||
|
||||
describe("shared secret registry", () => {
|
||||
beforeEach(() => {
|
||||
clearSecretRegistry();
|
||||
granted = new Set([PERMISSION]);
|
||||
auditEntries = [];
|
||||
});
|
||||
afterEach(() => clearSecretRegistry());
|
||||
|
||||
describe("registration", () => {
|
||||
it("round-trips an offer", () => {
|
||||
const registration = offer();
|
||||
|
||||
expect(getSecretRegistration(PROVIDER, KEY)).toBe(registration);
|
||||
expect(listSecrets()).toHaveLength(1);
|
||||
expect(registration.generation).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keys offers by plugin as well as name", () => {
|
||||
offer();
|
||||
offer({ pluginId: "other-plugin", resolve: () => "other-value" });
|
||||
|
||||
expect(listSecrets()).toHaveLength(2);
|
||||
expect(getSecretRegistration("other-plugin", KEY)?.pluginId).toBe(
|
||||
"other-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it("only withdraws the offer that is actually current", () => {
|
||||
const first = offer();
|
||||
const second = offer();
|
||||
|
||||
// A crashed-and-restarted provider must not withdraw the replacement its
|
||||
// own restart installed.
|
||||
expect(withdrawSecret(PROVIDER, KEY, first)).toBe(false);
|
||||
expect(getSecretRegistration(PROVIDER, KEY)).toBe(second);
|
||||
|
||||
expect(withdrawSecret(PROVIDER, KEY, second)).toBe(true);
|
||||
expect(getSecretRegistration(PROVIDER, KEY)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a withdraw of something never offered", () => {
|
||||
expect(withdrawSecret(PROVIDER, "nothing")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading", () => {
|
||||
it("resolves for a user who holds the permission", async () => {
|
||||
offer();
|
||||
|
||||
await expect(read()).resolves.toBe(SECRET);
|
||||
});
|
||||
|
||||
it("denies a user without the permission, though the secret exists", async () => {
|
||||
offer();
|
||||
granted = new Set();
|
||||
|
||||
// The whole point of the per-call check: the plugin is installed, the
|
||||
// key is set, and this user still gets nothing.
|
||||
await expect(read()).rejects.toBeInstanceOf(PluginSecretPermissionError);
|
||||
expect(getSecretRegistration(PROVIDER, KEY)).toBeDefined();
|
||||
});
|
||||
|
||||
it("denies with the 403 shape a route can pass straight through", async () => {
|
||||
offer();
|
||||
granted = new Set();
|
||||
|
||||
const error = await read().catch((caught) => caught);
|
||||
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.body).toEqual({
|
||||
error: "Insufficient permissions",
|
||||
required: PERMISSION,
|
||||
});
|
||||
});
|
||||
|
||||
it("never runs the provider's resolver for a denied read", async () => {
|
||||
const resolve = vi.fn(() => SECRET);
|
||||
offer({ resolve });
|
||||
granted = new Set();
|
||||
|
||||
await expect(read()).rejects.toBeInstanceOf(PluginSecretPermissionError);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when the provider is installed but the key is not set", async () => {
|
||||
offer({ resolve: () => null });
|
||||
|
||||
// Absence, not denial: the user may read it, there is just nothing there.
|
||||
await expect(read()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("treats an empty string as not set", async () => {
|
||||
offer({ resolve: () => "" });
|
||||
|
||||
await expect(read()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the providing plugin is not installed at all", async () => {
|
||||
await expect(read()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("gives the same answer for absent and unset, so neither leaks install state", async () => {
|
||||
const unset = await read();
|
||||
offer({ resolve: () => null });
|
||||
const absent = await read();
|
||||
|
||||
expect(unset).toBeNull();
|
||||
expect(absent).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a plugin reading something its manifest never declared", async () => {
|
||||
offer();
|
||||
|
||||
await expect(read(consumer([]), "user-1")).rejects.toBeInstanceOf(
|
||||
PluginSecretUndeclaredError,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a declared reference to a different key on the same plugin", async () => {
|
||||
offer();
|
||||
|
||||
await expect(
|
||||
read(consumer([{ plugin: PROVIDER, key: "other-key" }])),
|
||||
).rejects.toBeInstanceOf(PluginSecretUndeclaredError);
|
||||
});
|
||||
|
||||
it("refuses a read that cannot be attributed to a user", async () => {
|
||||
offer();
|
||||
|
||||
await expect(readAnonymously()).rejects.toBeInstanceOf(
|
||||
PluginSecretActorError,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for an anonymous read of an absent provider", async () => {
|
||||
// Absence is settled before the actor is, so a borrower whose provider
|
||||
// is gone gets the same null whether or not it had a user to act as.
|
||||
await expect(readAnonymously()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("passes the acting user to the resolver, so per-user keys stay per-user", async () => {
|
||||
const resolve = vi.fn((userId: string) => `key-for-${userId}`);
|
||||
offer({ resolve });
|
||||
|
||||
await expect(read(consumer(), "user-7")).resolves.toBe("key-for-user-7");
|
||||
expect(resolve).toHaveBeenCalledWith("user-7");
|
||||
});
|
||||
|
||||
it("propagates a resolver failure rather than reporting it as unset", async () => {
|
||||
offer({
|
||||
resolve: () => {
|
||||
throw new Error("vault unreachable");
|
||||
},
|
||||
});
|
||||
|
||||
// Reported as null, a consumer would fall back to a weaker path on what
|
||||
// is actually a transient provider fault.
|
||||
await expect(read()).rejects.toThrow("vault unreachable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("revocation takes effect immediately", () => {
|
||||
it("stops resolving the moment the permission is revoked", async () => {
|
||||
offer();
|
||||
await expect(read()).resolves.toBe(SECRET);
|
||||
|
||||
granted = new Set();
|
||||
|
||||
// No restart, no reinstall: the next call is already denied.
|
||||
await expect(read()).rejects.toBeInstanceOf(PluginSecretPermissionError);
|
||||
});
|
||||
|
||||
it("serves the rotated value on the next read, with nothing to invalidate", async () => {
|
||||
let current = SECRET;
|
||||
offer({ resolve: () => current });
|
||||
|
||||
await expect(read()).resolves.toBe(SECRET);
|
||||
current = "sk-live-rotated";
|
||||
await expect(read()).resolves.toBe("sk-live-rotated");
|
||||
});
|
||||
|
||||
it("nulls out a borrower's reference when the provider clears the secret", async () => {
|
||||
let current: string | null = SECRET;
|
||||
offer({ resolve: () => current });
|
||||
|
||||
await expect(read()).resolves.toBe(SECRET);
|
||||
current = null;
|
||||
await expect(read()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstalling the provider", () => {
|
||||
it("nulls out the reference rather than leaving a dangling handle", async () => {
|
||||
offer();
|
||||
await expect(read()).resolves.toBe(SECRET);
|
||||
|
||||
expect(withdrawAllForPlugin(PROVIDER)).toBe(1);
|
||||
|
||||
// The borrower is still installed, still declares the reference, and
|
||||
// still holds the permission. It simply gets null now, the same answer
|
||||
// as a provider that was never installed -- no throw, no stale value.
|
||||
await expect(read()).resolves.toBeNull();
|
||||
expect(getSecretRegistration(PROVIDER, KEY)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps no copy of the value behind after an uninstall", async () => {
|
||||
const resolve = vi.fn(() => SECRET);
|
||||
offer({ resolve });
|
||||
await read();
|
||||
resolve.mockClear();
|
||||
|
||||
withdrawAllForPlugin(PROVIDER);
|
||||
|
||||
// Nothing cached the earlier read: the answer comes from the absence of
|
||||
// a registration, not from a remembered value.
|
||||
await expect(read()).resolves.toBeNull();
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withdraws every key the plugin offered, not just the one read", () => {
|
||||
offer();
|
||||
offer({ key: "webhook-secret" });
|
||||
offer({ pluginId: "unrelated-plugin" });
|
||||
|
||||
expect(withdrawAllForPlugin(PROVIDER)).toBe(2);
|
||||
expect(listSecrets()).toHaveLength(1);
|
||||
expect(getSecretRegistration("unrelated-plugin", KEY)).toBeDefined();
|
||||
});
|
||||
|
||||
it("starts working again if the provider is reinstalled, with no borrower restart", async () => {
|
||||
offer();
|
||||
withdrawAllForPlugin(PROVIDER);
|
||||
await expect(read()).resolves.toBeNull();
|
||||
|
||||
offer({ resolve: () => "sk-live-reinstalled" });
|
||||
|
||||
await expect(read()).resolves.toBe("sk-live-reinstalled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("requirement resolution", () => {
|
||||
it("reports an offered reference as available", () => {
|
||||
offer();
|
||||
|
||||
const resolution = resolveSecretRequirements(consumer());
|
||||
|
||||
expect(resolution.available).toEqual([`${PROVIDER}:${KEY}`]);
|
||||
expect(resolution.unavailable).toEqual([]);
|
||||
expect(resolution.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an absent optional reference without an error", () => {
|
||||
const resolution = resolveSecretRequirements(consumer());
|
||||
|
||||
expect(resolution.unavailable).toEqual([`${PROVIDER}:${KEY}`]);
|
||||
expect(resolution.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an absent required reference as an error", () => {
|
||||
const resolution = resolveSecretRequirements(
|
||||
consumer([{ plugin: PROVIDER, key: KEY }]),
|
||||
);
|
||||
|
||||
expect(resolution.errors).toHaveLength(1);
|
||||
expect(resolution.errors[0]).toContain(PROVIDER);
|
||||
});
|
||||
|
||||
it("ignores any user's permissions, because activation is per-instance", () => {
|
||||
offer();
|
||||
granted = new Set();
|
||||
|
||||
// A user without the grant must get a clear denial from the call, not a
|
||||
// plugin that mysteriously refuses to start.
|
||||
expect(resolveSecretRequirements(consumer()).available).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is empty for a manifest that borrows nothing", () => {
|
||||
const resolution = resolveSecretRequirements({ id: "plain" } as never);
|
||||
|
||||
expect(resolution.available).toEqual([]);
|
||||
expect(resolution.unavailable).toEqual([]);
|
||||
expect(resolution.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit", () => {
|
||||
it("records an allowed read without the value", async () => {
|
||||
offer();
|
||||
await read();
|
||||
|
||||
expect(auditEntries).toHaveLength(1);
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
consumerPluginId: "reporting-plugin",
|
||||
providerPluginId: PROVIDER,
|
||||
key: KEY,
|
||||
success: true,
|
||||
resolved: true,
|
||||
});
|
||||
expect(JSON.stringify(auditEntries[0])).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it("records a denial", async () => {
|
||||
offer();
|
||||
granted = new Set();
|
||||
await read().catch(() => undefined);
|
||||
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
success: false,
|
||||
resolved: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes an allowed read that found nothing", async () => {
|
||||
offer({ resolve: () => null });
|
||||
await read();
|
||||
|
||||
expect(auditEntries[0]).toMatchObject({ success: true, resolved: false });
|
||||
});
|
||||
|
||||
it("writes nothing when the provider is not installed", async () => {
|
||||
await read();
|
||||
|
||||
// There is no provider to attribute the read to, and no decision was
|
||||
// made about the user -- an audit line here would be noise.
|
||||
expect(auditEntries).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* The actor cannot be spoofed. It is set by a request or by ctx.asUser, and
|
||||
* every other way of naming a user (a service handle, a shared secret read)
|
||||
* is audited the same way asUser is. RBAC inside ctx.hosts and ctx.ssh is
|
||||
* applied for that actor, so a user without access to a host cannot reach it
|
||||
* through a plugin.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
const h = vi.hoisted(() => ({
|
||||
reachable: new Set<number>([1]),
|
||||
resolvedFor: [] as Array<{ id: number; userId: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async () =>
|
||||
["hosts:read", "ssh:connect", "credentials:use"].map((capability) => ({
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async () => "owner",
|
||||
findHostById: async (id: number) => ({
|
||||
id,
|
||||
userId: "owner",
|
||||
name: "box",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async () => true,
|
||||
canAccessHost: async (_userId: string, hostId: number) => ({
|
||||
hasAccess: h.reachable.has(hostId),
|
||||
isOwner: false,
|
||||
isShared: h.reachable.has(hostId),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (id: number, userId: string) => {
|
||||
h.resolvedFor.push({ id, userId });
|
||||
return h.reachable.has(id) ? { id, userId: "owner", ip: "10.0.0.1" } : null;
|
||||
},
|
||||
resolveHostBySyncId: async () => null,
|
||||
}));
|
||||
|
||||
const { createPluginContext, createPluginHandle } =
|
||||
await import("../../../plugins/ctx.js");
|
||||
const { invalidatePluginPermissionCache } =
|
||||
await import("../../../plugins/permissions.js");
|
||||
const { runAsActor, getActor } = await import("../../../plugins/actor.js");
|
||||
const { clearServiceRegistry } =
|
||||
await import("../../../plugins/service-registry.js");
|
||||
|
||||
function contextFor(pluginId: string, extra: Record<string, unknown> = {}) {
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return createPluginContext(
|
||||
{
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [
|
||||
"hosts:read",
|
||||
"ssh:connect",
|
||||
"credentials:use",
|
||||
"users:impersonate",
|
||||
],
|
||||
requires: [{ service: "sample.echo", versionRange: "^1.0.0" }],
|
||||
...extra,
|
||||
} as never,
|
||||
handle,
|
||||
);
|
||||
}
|
||||
|
||||
const asUserLines = () =>
|
||||
auditEntries.filter((entry) => entry.action === "plugin_as_user");
|
||||
|
||||
beforeEach(() => {
|
||||
auditEntries.length = 0;
|
||||
h.resolvedFor = [];
|
||||
h.reachable = new Set([1]);
|
||||
invalidatePluginPermissionCache();
|
||||
});
|
||||
afterEach(() => clearServiceRegistry());
|
||||
|
||||
describe("naming a user is audited like ctx.asUser", () => {
|
||||
function provider() {
|
||||
const ctx = contextFor("provider", {
|
||||
provides: [
|
||||
{ service: "sample.echo", version: "1.0.0", permission: "x.use" },
|
||||
],
|
||||
});
|
||||
ctx.services.provide("sample.echo", {
|
||||
whoAmI: async () => getActor(),
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
it("ctx.asUser writes an as_user line", async () => {
|
||||
const ctx = contextFor("caller");
|
||||
await ctx.asUser("victim", async () => undefined);
|
||||
expect(asUserLines()).toHaveLength(1);
|
||||
expect(asUserLines()[0]).toMatchObject({ resourceId: "caller" });
|
||||
});
|
||||
|
||||
it("services.get({ userId }) for someone else writes one too", async () => {
|
||||
provider();
|
||||
const caller = contextFor("caller");
|
||||
const handle = caller.services.get<{ whoAmI: () => Promise<string> }>(
|
||||
"sample.echo",
|
||||
{ userId: "victim" },
|
||||
);
|
||||
await runAsActor("alice", "request", async () => {
|
||||
expect(await handle.whoAmI()).toBe("victim");
|
||||
});
|
||||
expect(asUserLines()).toHaveLength(1);
|
||||
expect(String(asUserLines()[0].details)).toMatch(/victim/);
|
||||
});
|
||||
|
||||
it("a handle's asUser(userId) writes one too", async () => {
|
||||
provider();
|
||||
const caller = contextFor("caller");
|
||||
const handle = caller.services.get<{
|
||||
asUser: (userId: string) => { whoAmI: () => Promise<string> };
|
||||
}>("sample.echo");
|
||||
await runAsActor("alice", "request", async () => {
|
||||
expect(await handle.asUser("victim").whoAmI()).toBe("victim");
|
||||
});
|
||||
expect(asUserLines()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("naming the actor itself is not a switch and writes nothing", async () => {
|
||||
provider();
|
||||
const caller = contextFor("caller");
|
||||
const handle = caller.services.get<{ whoAmI: () => Promise<string> }>(
|
||||
"sample.echo",
|
||||
{ userId: "alice" },
|
||||
);
|
||||
await runAsActor("alice", "request", () => handle.whoAmI());
|
||||
expect(asUserLines()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("the provider sees the user the permission was checked for", async () => {
|
||||
provider();
|
||||
const caller = contextFor("caller");
|
||||
const handle = caller.services.get<{ whoAmI: () => Promise<string> }>(
|
||||
"sample.echo",
|
||||
);
|
||||
await runAsActor("alice", "request", async () => {
|
||||
expect(await handle.whoAmI()).toBe("alice");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a user cannot reach a host they have no access to", () => {
|
||||
it("ctx.hosts answers nothing for it", async () => {
|
||||
const ctx = contextFor("reader");
|
||||
await runAsActor("alice", "request", async () => {
|
||||
expect(await ctx.hosts.get(99)).toBeNull();
|
||||
expect(await ctx.hosts.status.get(99)).toBeNull();
|
||||
expect(await ctx.hosts.status.check(99)).toBeNull();
|
||||
const access = await ctx.hosts.checkAccess(99, "connect");
|
||||
expect(access.hasAccess).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("ctx.ssh resolves it as the actor and gets nothing", async () => {
|
||||
const ctx = contextFor("reader");
|
||||
await runAsActor("alice", "request", async () => {
|
||||
expect(await ctx.ssh.resolveHost(99)).toBeNull();
|
||||
});
|
||||
expect(h.resolvedFor).toEqual([{ id: 99, userId: "alice" }]);
|
||||
});
|
||||
|
||||
it("ctx.ssh refuses everything without an actor, whatever the host says", async () => {
|
||||
const ctx = contextFor("reader");
|
||||
await expect(ctx.ssh.resolveHost(1)).rejects.toThrow(/acting user/);
|
||||
await expect(
|
||||
ctx.ssh.connect({
|
||||
id: 1,
|
||||
userId: "owner",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
}),
|
||||
).rejects.toThrow(/acting user/);
|
||||
await expect(
|
||||
ctx.ssh.jumpChain([{ hostId: 1 }], {
|
||||
forHost: {
|
||||
id: 1,
|
||||
userId: "owner",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/acting user/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* /plugin-assets serves only a running plugin's browser files: never a path
|
||||
* outside its folder (however it is encoded, or through a symlink), never its
|
||||
* backend or migrations, and always with a fixed content type and nosniff.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPluginAssetsRouter,
|
||||
resolvePluginAsset,
|
||||
} from "../../../plugins/assets.js";
|
||||
|
||||
let root = "";
|
||||
let server: Server;
|
||||
let baseUrl = "";
|
||||
const plugins = new Map<string, Record<string, unknown>>();
|
||||
|
||||
function write(relative: string, content: string) {
|
||||
const file = path.join(root, relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
|
||||
/** Creates a link, or returns false where the OS will not let us. */
|
||||
function link(target: string, at: string, type: "file" | "junction"): boolean {
|
||||
try {
|
||||
fs.symlinkSync(target, at, type);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let fileLinks = false;
|
||||
let dirLinks = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-assets-sec-"));
|
||||
write("gizmo/dist/frontend.js", "export function activate() {}");
|
||||
write("gizmo/dist/backend.js", "secret()");
|
||||
write("gizmo/dist/migrations/snapshot.json", "{}");
|
||||
write("gizmo/locales/en.json", "{}");
|
||||
write("secret.js", "outside()");
|
||||
write("secrets/keys.json", "{}");
|
||||
|
||||
// A plugin whose manifest puts its frontend at the plugin root, so the
|
||||
// backend and migrations sit in the served folder.
|
||||
write("flat/frontend.js", "export function activate() {}");
|
||||
write("flat/server/main.js", "secret()");
|
||||
write(
|
||||
"flat/migrations/sqlite/0001_init.sql",
|
||||
"CREATE TABLE p_flat_x (id int);",
|
||||
);
|
||||
|
||||
fileLinks = link(
|
||||
path.join(root, "secret.js"),
|
||||
path.join(root, "gizmo/dist/escape.js"),
|
||||
"file",
|
||||
);
|
||||
dirLinks = link(
|
||||
path.join(root, "secrets"),
|
||||
path.join(root, "gizmo/dist/linked"),
|
||||
"junction",
|
||||
);
|
||||
|
||||
plugins.set("gizmo", {
|
||||
id: "gizmo",
|
||||
dir: path.join(root, "gizmo"),
|
||||
manifest: { id: "gizmo", version: "1.0.0" },
|
||||
state: "active",
|
||||
});
|
||||
plugins.set("flat", {
|
||||
id: "flat",
|
||||
dir: path.join(root, "flat"),
|
||||
manifest: {
|
||||
id: "flat",
|
||||
version: "1.0.0",
|
||||
frontend: "frontend.js",
|
||||
backend: "server/main.js",
|
||||
},
|
||||
state: "active",
|
||||
});
|
||||
plugins.set("stopped", {
|
||||
id: "stopped",
|
||||
dir: path.join(root, "gizmo"),
|
||||
manifest: { id: "stopped", version: "1.0.0" },
|
||||
state: "disabled",
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
createPluginAssetsRouter((id) => plugins.get(id) as never),
|
||||
);
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const get = (url: string) => fetch(`${baseUrl}/plugin-assets/${url}`);
|
||||
|
||||
describe("path traversal", () => {
|
||||
it.each([
|
||||
"gizmo/../secret.js",
|
||||
"gizmo/..%2fsecret.js",
|
||||
"gizmo/%2e%2e/secret.js",
|
||||
"gizmo/%2e%2e%2f%2e%2e%2fsecret.js",
|
||||
"gizmo/..%5csecret.js",
|
||||
"gizmo/%2e%2e%5c%2e%2e%5csecret.js",
|
||||
"gizmo/frontend.js%00.js",
|
||||
"gizmo/locales/..%2f..%2fsecret.js",
|
||||
"gizmo/%2fetc%2fpasswd.js",
|
||||
])("refuses %s", async (url) => {
|
||||
const response = await get(url);
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).not.toContain("outside()");
|
||||
});
|
||||
|
||||
it("refuses a symlinked file that points outside the plugin", () => {
|
||||
if (!fileLinks) return;
|
||||
expect(
|
||||
resolvePluginAsset(plugins.get("gizmo") as never, "escape.js"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a file inside a symlinked folder that points outside", () => {
|
||||
if (!dirLinks) return;
|
||||
expect(
|
||||
resolvePluginAsset(plugins.get("gizmo") as never, "linked/keys.json"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("server-only files", () => {
|
||||
it("never serves the backend entry, wherever the manifest put it", async () => {
|
||||
expect((await get("gizmo/backend.js")).status).toBe(404);
|
||||
expect((await get("flat/server/main.js")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("never serves anything under migrations", async () => {
|
||||
expect((await get("gizmo/migrations/snapshot.json")).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a stopped plugin", () => {
|
||||
it("serves nothing, so its bundle cannot be loaded around the shell", async () => {
|
||||
expect((await get("stopped/frontend.js")).status).toBe(404);
|
||||
expect((await get("gizmo/frontend.js")).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("headers", () => {
|
||||
it("sends a fixed content type and nosniff", async () => {
|
||||
const script = await get("gizmo/frontend.js");
|
||||
expect(script.headers.get("content-type")).toMatch(/^text\/javascript/);
|
||||
expect(script.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
|
||||
const json = await get("gizmo/locales/en.json");
|
||||
expect(json.headers.get("content-type")).toMatch(/^application\/json/);
|
||||
expect(json.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
|
||||
const missing = await get("gizmo/nothing.js");
|
||||
expect(missing.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Auth: second factors fail closed, the public login list leaks nothing,
|
||||
* external sign-in cannot claim someone else's account or username, and the
|
||||
* lockout guard keeps password login on when nothing else can sign anyone in.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import {
|
||||
createAuthState,
|
||||
fakeAuthManager,
|
||||
fakeRequest,
|
||||
type AuthState,
|
||||
} from "../../auth/auth-test-helpers.js";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
state: null as unknown as AuthState,
|
||||
manager: null as unknown as ReturnType<
|
||||
typeof import("../../auth/auth-test-helpers.js").fakeAuthManager
|
||||
>,
|
||||
trustedProxy: false,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", async () => {
|
||||
const helpers = await import("../../auth/auth-test-helpers.js");
|
||||
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
|
||||
return Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
(...args: unknown[]) =>
|
||||
(
|
||||
helpers.fakeFactory(h.state) as Record<
|
||||
string,
|
||||
(...a: unknown[]) => unknown
|
||||
>
|
||||
)[name](...args),
|
||||
]),
|
||||
);
|
||||
});
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => h.manager },
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
h.state.audits.push(entry);
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../../hosts/internal-events.js", () => ({
|
||||
emitInternalEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
return {
|
||||
authLogger: log,
|
||||
databaseLogger: log,
|
||||
sshLogger: log,
|
||||
logger: log,
|
||||
systemLogger: log,
|
||||
};
|
||||
});
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/trusted-proxy-auth.js", () => ({
|
||||
isTrustedProxyAuthEnabled: () => h.trustedProxy,
|
||||
}));
|
||||
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/shared-credential-secrets-manager.js", () => ({
|
||||
SharedCredentialSecretsManager: {
|
||||
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
const { runLogin, resetPendingLoginsForTests } =
|
||||
await import("../../../auth/login-pipeline.js");
|
||||
const { verifyPasswordLogin } =
|
||||
await import("../../../auth/builtin-login-methods.js");
|
||||
const { getPasswordLoginStatus } = await import("../../../auth/core-auth.js");
|
||||
const { registerLoginMethod, registerSecondFactor } =
|
||||
await import("../../../auth/registry.js");
|
||||
const { listPublicLoginMethods } =
|
||||
await import("../../../database/routes/auth-routes.js");
|
||||
const { loginRateLimiter } =
|
||||
await import("../../../utils/login-rate-limiter.js");
|
||||
|
||||
const PASSWORD = "correct-horse";
|
||||
|
||||
function addUser(overrides: Record<string, unknown> = {}) {
|
||||
const user = {
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
passwordHash: bcrypt.hashSync(PASSWORD, 4),
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
...overrides,
|
||||
};
|
||||
h.state.users.set(user.id as string, user as never);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function passwordIdentity() {
|
||||
const req = fakeRequest({ body: { username: "alice", password: PASSWORD } });
|
||||
return { req, identity: await verifyPasswordLogin(req as never) };
|
||||
}
|
||||
|
||||
const disposers: Array<() => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
h.state = createAuthState();
|
||||
h.manager = fakeAuthManager(h.state);
|
||||
h.trustedProxy = false;
|
||||
resetPendingLoginsForTests();
|
||||
loginRateLimiter.resetAttempts("10.0.0.1", "alice");
|
||||
delete process.env.ALLOW_PASSWORD_LOGIN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (disposers.length) disposers.pop()?.();
|
||||
});
|
||||
|
||||
describe("second factors fail closed", () => {
|
||||
it("refuses the login when a factor cannot say whether the user is enrolled", async () => {
|
||||
addUser();
|
||||
disposers.push(
|
||||
registerSecondFactor({
|
||||
id: "flaky",
|
||||
pluginId: "fixture",
|
||||
labelKey: "k",
|
||||
isEnrolled: async () => {
|
||||
throw new Error("database is locked");
|
||||
},
|
||||
verify: async () => true,
|
||||
}),
|
||||
);
|
||||
const { req, identity } = await passwordIdentity();
|
||||
await expect(
|
||||
runLogin(req as never, identity, {
|
||||
methodId: "password",
|
||||
rememberMe: false,
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
expect(h.manager.generateJWTToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the public login list", () => {
|
||||
it("names methods and enabled instances, and nothing else", async () => {
|
||||
disposers.push(
|
||||
registerLoginMethod({
|
||||
id: "corp-sso",
|
||||
pluginId: "fixture",
|
||||
labelKey: "k",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
describe: async () =>
|
||||
[
|
||||
{
|
||||
id: "1",
|
||||
label: "Corp",
|
||||
enabled: true,
|
||||
clientSecret: "shh",
|
||||
issuerUrl: "https://idp.internal",
|
||||
},
|
||||
{ id: "2", label: "Old", enabled: false },
|
||||
] as never,
|
||||
}),
|
||||
);
|
||||
const methods = await listPublicLoginMethods();
|
||||
const sso = methods.find((method) => method.id === "corp-sso");
|
||||
expect(sso?.instances).toEqual([{ id: "1", label: "Corp" }]);
|
||||
expect(JSON.stringify(methods)).not.toMatch(/shh|idp\.internal/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("external sign-in cannot claim another account", () => {
|
||||
const external = {
|
||||
kind: "external" as const,
|
||||
provider: "3",
|
||||
subject: "sub-1",
|
||||
email: null,
|
||||
name: "Alice",
|
||||
legacyIdentifier: "shared-id",
|
||||
ssoProviderId: 3,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
disposers.push(
|
||||
registerLoginMethod({
|
||||
id: "oidc",
|
||||
pluginId: "sso",
|
||||
labelKey: "k",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
}),
|
||||
);
|
||||
h.state.settings.set("oidc_auto_provision", "true");
|
||||
});
|
||||
|
||||
async function signIn(identity: Record<string, unknown> = external) {
|
||||
return runLogin(fakeRequest() as never, identity as never, {
|
||||
methodId: "oidc",
|
||||
rememberMe: false,
|
||||
});
|
||||
}
|
||||
|
||||
function linkedTo(): string | undefined {
|
||||
return h.state.identities.find((row) => row.subject === "sub-1")?.userId;
|
||||
}
|
||||
|
||||
it("never links a local password account by its old identifier", async () => {
|
||||
addUser({ id: "local", oidcIdentifier: "shared-id", isOidc: false });
|
||||
await signIn();
|
||||
expect(linkedTo()).not.toBe("local");
|
||||
});
|
||||
|
||||
it("never links an account that already has an identity", async () => {
|
||||
addUser({
|
||||
id: "taken",
|
||||
username: "bob",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
oidcIdentifier: "shared-id",
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "taken",
|
||||
providerId: "9",
|
||||
subject: "bob",
|
||||
email: null,
|
||||
});
|
||||
await signIn();
|
||||
expect(linkedTo()).not.toBe("taken");
|
||||
});
|
||||
|
||||
it("never links an account from a different provider", async () => {
|
||||
addUser({
|
||||
id: "other",
|
||||
username: "bob",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
oidcIdentifier: "shared-id",
|
||||
ssoProviderId: 7,
|
||||
});
|
||||
await signIn();
|
||||
expect(linkedTo()).not.toBe("other");
|
||||
});
|
||||
|
||||
it("still links a genuine 2.8 account on its first sign-in", async () => {
|
||||
addUser({
|
||||
id: "legacy",
|
||||
username: "Alice",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
oidcIdentifier: "shared-id",
|
||||
ssoProviderId: 3,
|
||||
});
|
||||
await signIn();
|
||||
expect(linkedTo()).toBe("legacy");
|
||||
});
|
||||
|
||||
it("never gives a new account a local user's username", async () => {
|
||||
addUser({ id: "admin-local", username: "admin" });
|
||||
await signIn({ ...external, name: "admin", legacyIdentifier: undefined });
|
||||
const created = [...h.state.users.values()].find(
|
||||
(user) => user.id !== "admin-local",
|
||||
);
|
||||
expect(created?.username).not.toBe("admin");
|
||||
expect(created?.username).toMatch(/^admin-/);
|
||||
});
|
||||
|
||||
it("never renames an existing account onto a local user's username", async () => {
|
||||
addUser({ id: "admin-local", username: "admin" });
|
||||
addUser({
|
||||
id: "sso-user",
|
||||
username: "carol",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "sso-user",
|
||||
providerId: "3",
|
||||
subject: "sub-1",
|
||||
email: null,
|
||||
});
|
||||
await signIn({ ...external, name: "admin" });
|
||||
expect(h.state.users.get("sso-user")?.username).toBe("carol");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the lockout guard", () => {
|
||||
it("keeps password login on when it is off and nothing else can sign in", async () => {
|
||||
process.env.ALLOW_PASSWORD_LOGIN = "false";
|
||||
await expect(getPasswordLoginStatus()).resolves.toEqual({
|
||||
allowed: true,
|
||||
forced: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets it go off once another method has an enabled instance", async () => {
|
||||
process.env.ALLOW_PASSWORD_LOGIN = "false";
|
||||
disposers.push(
|
||||
registerLoginMethod({
|
||||
id: "corp-sso",
|
||||
pluginId: "fixture",
|
||||
labelKey: "k",
|
||||
kind: "redirect",
|
||||
describe: async () => [{ id: "1", label: "Corp", enabled: true }],
|
||||
}),
|
||||
);
|
||||
await expect(getPasswordLoginStatus()).resolves.toEqual({
|
||||
allowed: false,
|
||||
forced: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not count a method whose instances are all off", async () => {
|
||||
process.env.ALLOW_PASSWORD_LOGIN = "false";
|
||||
disposers.push(
|
||||
registerLoginMethod({
|
||||
id: "corp-sso",
|
||||
pluginId: "fixture",
|
||||
labelKey: "k",
|
||||
kind: "redirect",
|
||||
describe: async () => [{ id: "1", label: "Corp", enabled: false }],
|
||||
}),
|
||||
);
|
||||
await expect(getPasswordLoginStatus()).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
forced: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* No ctx method hands a plugin plaintext credential material unless the
|
||||
* plugin holds credentials:read. Every return value is scanned for secret
|
||||
* keys, nested ones included, so a new field on the host row that carries a
|
||||
* secret shows up here.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const SECRET = "s3cret-value";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
actor: "user-1" as string | undefined,
|
||||
connected: [] as unknown[],
|
||||
}));
|
||||
|
||||
function secretHost(id: number): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
userId: "user-1",
|
||||
name: "box",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
tags: null,
|
||||
folder: null,
|
||||
password: "s3cret-value",
|
||||
key: "s3cret-value",
|
||||
keyPassword: "s3cret-value",
|
||||
sudoPassword: "s3cret-value",
|
||||
socks5Password: "s3cret-value",
|
||||
terminalConfig: { sudoPassword: "s3cret-value", fontSize: 14 },
|
||||
socks5ProxyChain: [{ host: "p", password: "s3cret-value" }],
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const granted = (capability: string, declared: readonly string[]) =>
|
||||
declared.includes(capability) && h.granted.has(capability);
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!granted(capability, declared)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
hasCapability: async (
|
||||
_pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => granted(capability, declared),
|
||||
hasCachedCapability: (
|
||||
_pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => granted(capability, declared),
|
||||
warmPluginGrants: () => {},
|
||||
capabilityRefused: (pluginId: string, capability: string) =>
|
||||
new PluginCapabilityError(pluginId, capability),
|
||||
};
|
||||
});
|
||||
vi.mock("../../../plugins/actor.js", () => ({
|
||||
getActor: () => h.actor,
|
||||
getActorSessionId: () => undefined,
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async () => ({ hasAccess: true, isOwner: true }),
|
||||
hasPermission: async () => true,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/index.js", () => ({
|
||||
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
||||
applyDefaultsAfterHostWrite: async () => {},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listHosts: async () => [],
|
||||
}),
|
||||
createCurrentHostRepository: () => ({
|
||||
createEncryptedForUser: async () => secretHost(1),
|
||||
updateEncryptedForUser: async () => secretHost(1),
|
||||
listDecryptedByUserId: async () => [secretHost(1), secretHost(2)],
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostsByUserId: async () => [secretHost(1)],
|
||||
listHostRowsForAccessList: async () => [secretHost(2)],
|
||||
findHostOwnerId: async () => "user-1",
|
||||
findHostById: async (id: number) => secretHost(id),
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
|
||||
createCurrentRbacAccessRepository: () => ({
|
||||
listVisibleHostAccessEntries: async () => [],
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
loadHostPluginSettings: async () => new Map(),
|
||||
}));
|
||||
vi.mock("../../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (id: number) => secretHost(id),
|
||||
resolveHostBySyncId: async () => secretHost(9),
|
||||
}));
|
||||
vi.mock("../../../hosts/connect/connect-host.js", () => ({
|
||||
connectHost: async (target: unknown) => {
|
||||
h.connected.push(target);
|
||||
return {
|
||||
client: { once: () => {} },
|
||||
jumpClient: null,
|
||||
host: typeof target === "number" ? secretHost(target) : target,
|
||||
dispose: () => {},
|
||||
};
|
||||
},
|
||||
resolveConnectHost: async (target: unknown) =>
|
||||
typeof target === "number" ? secretHost(target) : target,
|
||||
}));
|
||||
|
||||
const { createPluginHosts, redactHostSecrets, HOST_SECRET_FIELDS } =
|
||||
await import("../../../plugins/ctx-hosts.js");
|
||||
const { createPluginSsh } = await import("../../../plugins/ctx-ssh-auth.js");
|
||||
const { DisposableBag } = await import("../../../plugins/disposables.js");
|
||||
|
||||
function manifest(capabilities: string[]) {
|
||||
return {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never;
|
||||
}
|
||||
|
||||
/** Every path in `value` that holds the secret. */
|
||||
function leaks(value: unknown, path = "$"): string[] {
|
||||
if (value === SECRET) return [path];
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry, index) => leaks(entry, `${path}[${index}]`));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.entries(value).flatMap(([key, entry]) =>
|
||||
leaks(entry, `${path}.${key}`),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const audit = vi.fn(async () => {});
|
||||
|
||||
beforeEach(() => {
|
||||
h.actor = "user-1";
|
||||
h.granted = new Set();
|
||||
h.connected = [];
|
||||
audit.mockClear();
|
||||
});
|
||||
|
||||
describe("redactHostSecrets", () => {
|
||||
it("removes every secret field, nested ones too", () => {
|
||||
const redacted = redactHostSecrets(secretHost(1));
|
||||
expect(leaks(redacted)).toEqual([]);
|
||||
for (const field of HOST_SECRET_FIELDS) {
|
||||
expect(redacted).not.toHaveProperty(field);
|
||||
}
|
||||
expect(redacted).toMatchObject({ id: 1, ip: "10.0.0.1" });
|
||||
expect(redacted.terminalConfig).toEqual({ fontSize: 14 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts never returns secrets", () => {
|
||||
const all = ["hosts:read", "hosts:write"];
|
||||
|
||||
it("list, get, create, update and listOwned", async () => {
|
||||
h.granted = new Set(all);
|
||||
const hosts = createPluginHosts({ manifest: manifest(all), audit });
|
||||
const results = {
|
||||
list: await hosts.list(),
|
||||
get: await hosts.get(1),
|
||||
create: await hosts.create({
|
||||
name: "n",
|
||||
ip: "i",
|
||||
port: 22,
|
||||
username: "u",
|
||||
authType: "password",
|
||||
}),
|
||||
update: await hosts.update(1, { name: "m" }),
|
||||
listOwned: await hosts.listOwned(),
|
||||
};
|
||||
expect(leaks(results)).toEqual([]);
|
||||
});
|
||||
|
||||
it("audits listOwned, the widest read it has", async () => {
|
||||
h.granted = new Set(all);
|
||||
const hosts = createPluginHosts({ manifest: manifest(all), audit });
|
||||
await hosts.listOwned();
|
||||
expect(audit).toHaveBeenCalledWith("hosts_list_owned", "2 host(s)", {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh hands secrets only to credentials:read", () => {
|
||||
const connectPair = ["ssh:connect", "credentials:use"];
|
||||
|
||||
it("connect() returns the host without its secrets", async () => {
|
||||
h.granted = new Set(connectPair);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(connectPair),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
const connection = await ssh.connect(1);
|
||||
expect(leaks(connection.host)).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolveHost() redacts without credentials:read", async () => {
|
||||
h.granted = new Set(connectPair);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(connectPair),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
expect(leaks(await ssh.resolveHost(1))).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolveHost() gives the full host with credentials:read, and audits it", async () => {
|
||||
const withRead = [...connectPair, "credentials:read"];
|
||||
h.granted = new Set(withRead);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(withRead),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
expect(leaks(await ssh.resolveHost(1)).length).toBeGreaterThan(0);
|
||||
expect(audit).toHaveBeenCalledWith("credentials_read", "host 1", {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("a redacted host handed back still connects with the real secrets", async () => {
|
||||
h.granted = new Set(connectPair);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(connectPair),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit,
|
||||
});
|
||||
const redacted = await ssh.resolveHost(1);
|
||||
await ssh.connect(redacted!);
|
||||
expect(h.connected[0]).toMatchObject({ password: SECRET });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Every privileged ctx method checks its capability and leaves an audit line.
|
||||
*
|
||||
* This walks the whole ctx object rather than listing methods by hand, so a
|
||||
* member added later without a guard fails here until it is either guarded
|
||||
* or added to UNGATED with a reason.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
const { createPluginContext, createPluginHandle } =
|
||||
await import("../../../plugins/ctx.js");
|
||||
const { invalidatePluginPermissionCache } =
|
||||
await import("../../../plugins/permissions.js");
|
||||
const { runAsActor } = await import("../../../plugins/actor.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
const PLUGIN_ID = "walker";
|
||||
const ACTOR = "user-walker";
|
||||
|
||||
/**
|
||||
* Members that need no capability, and why. Anything not listed here must
|
||||
* refuse a plugin that declared and was granted nothing.
|
||||
*/
|
||||
const UNGATED: Record<string, string> = {
|
||||
"log.debug": "writes to core's log under the plugin's own tag",
|
||||
"log.info": "writes to core's log under the plugin's own tag",
|
||||
"log.warn": "writes to core's log under the plugin's own tag",
|
||||
"log.error": "writes to core's log under the plugin's own tag",
|
||||
"events.emit": "namespace check: plugin.<id>.* only without events:core",
|
||||
"events.on": "namespace check: plugin.* only without events:core",
|
||||
"sync.registerEntity": "adds the plugin's own table to remote sync",
|
||||
"registry.provide": "the plugin's own registry key",
|
||||
"registry.consume": "reads what another plugin chose to publish",
|
||||
"registry.revoke": "removes the plugin's own registry key",
|
||||
"services.provide": "only services declared in manifest provides",
|
||||
"services.get": "each call is checked against the service's permission",
|
||||
"services.providers": "names only",
|
||||
"secrets.offer": "only secrets declared in manifest providesSecret",
|
||||
"secrets.withdraw": "the plugin's own offer",
|
||||
"secrets.getShared": "each read is checked against the secret's permission",
|
||||
"settings.get": "the plugin's own admin settings",
|
||||
"settings.set": "the plugin's own admin settings",
|
||||
"settings.getUser": "the plugin's own user settings",
|
||||
"settings.setUser": "the plugin's own user settings",
|
||||
"settings.getHost": "the plugin's own host settings",
|
||||
"settings.getHostFor":
|
||||
"the plugin's own host settings, as one user sees them",
|
||||
"settings.getHostDefault":
|
||||
"the plugin's own host settings, as a user's defaults",
|
||||
"settings.setHost": "the plugin's own host settings",
|
||||
"settings.getAll": "the plugin's own settings",
|
||||
"settings.onChange": "the plugin's own settings",
|
||||
"settings.onValidate": "the plugin's own settings",
|
||||
"rbac.has": "answers for the actor only",
|
||||
"rbac.hasFor": "a yes/no about a role permission, grants nothing",
|
||||
"rbac.require": "route middleware for the plugin's own permissions",
|
||||
"capabilities.has": "a yes/no about the plugin's own grants",
|
||||
"disposables.add": "the plugin's own cleanup",
|
||||
"schedule.every": "the plugin's own timers",
|
||||
"schedule.after": "the plugin's own timers",
|
||||
"audit.record": "writes under the runtime's actor, never a plugin value",
|
||||
asUser: "always audited as plugin_as_user",
|
||||
currentActor: "reads the actor, cannot set it",
|
||||
"desktop.available": "a yes/no about the environment",
|
||||
"http.baseUrl": "reads the request's public origin",
|
||||
"ssh.poolKey": "a string built from a host the plugin already holds",
|
||||
"ssh.dropPooled": "closes only this plugin's own pooled connections",
|
||||
"ssh.classifyKeyboardInteractive": "pure classification of a prompt",
|
||||
"ssh.autoResponses": "pure mapping of prompts to answers",
|
||||
"ssh.requiresSecret": "a yes/no about an auth type",
|
||||
"ssh.supportsBackground": "a yes/no about an auth type",
|
||||
};
|
||||
|
||||
function walk(
|
||||
value: unknown,
|
||||
path: string[] = [],
|
||||
out: Array<{ path: string; fn: (...args: unknown[]) => unknown }> = [],
|
||||
) {
|
||||
if (!value || typeof value !== "object") return out;
|
||||
for (const [key, member] of Object.entries(value)) {
|
||||
if (path.length === 0 && (key === "manifest" || key === "pluginId")) {
|
||||
continue;
|
||||
}
|
||||
const at = [...path, key];
|
||||
if (typeof member === "function") {
|
||||
out.push({ path: at.join("."), fn: member as never });
|
||||
}
|
||||
if (member && typeof member === "object") walk(member, at, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Args that get a call past argument checks and into the guard. */
|
||||
const PLACEHOLDER_ARGS = [
|
||||
1,
|
||||
{ id: 1, ip: "10.0.0.1", port: 22, username: "u", type: "x" },
|
||||
{ purpose: "background", pool: "p" },
|
||||
() => undefined,
|
||||
];
|
||||
|
||||
function contextWithNothing() {
|
||||
const handle = createPluginHandle(PLUGIN_ID, { activate: () => {} });
|
||||
return createPluginContext(
|
||||
{
|
||||
id: PLUGIN_ID,
|
||||
name: "Walker",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
} as never,
|
||||
handle,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
});
|
||||
|
||||
describe("every privileged ctx member is guarded", () => {
|
||||
const members = walk(contextWithNothing());
|
||||
|
||||
it("finds the members it walks", () => {
|
||||
expect(members.length).toBeGreaterThan(60);
|
||||
for (const name of Object.keys(UNGATED)) {
|
||||
expect(
|
||||
members.some((member) => member.path === name),
|
||||
`UNGATED lists ${name}, which ctx no longer has`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
const gated = members.filter((member) => !(member.path in UNGATED));
|
||||
|
||||
it.each(gated.map((member) => [member.path]))(
|
||||
"%s refuses a plugin with no grants and audits it",
|
||||
async (path) => {
|
||||
const ctx = contextWithNothing();
|
||||
const member = walk(ctx).find((entry) => entry.path === path)!;
|
||||
|
||||
let caught: unknown;
|
||||
await runAsActor(ACTOR, "request", async () => {
|
||||
try {
|
||||
await member.fn(...PLACEHOLDER_ARGS);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
});
|
||||
// Sync refusals write their audit line in the background.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(
|
||||
caught,
|
||||
`${path} ran without a capability. Guard it, or add it to UNGATED with a reason.`,
|
||||
).toBeInstanceOf(PluginCapabilityError);
|
||||
expect(
|
||||
auditEntries.some(
|
||||
(entry) =>
|
||||
entry.resourceId === PLUGIN_ID &&
|
||||
entry.userId === ACTOR &&
|
||||
entry.success === false,
|
||||
),
|
||||
`${path} refused without an audit line naming the plugin and actor`,
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Data: a plugin migration can only write tables under its own prefix, the
|
||||
* CLI and the runtime share one checker, a symlink cannot feed in SQL from
|
||||
* elsewhere, and removing a plugin's data drops only that plugin's tables.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectOwnedIndexes,
|
||||
findUnownedTableWrites,
|
||||
} from "@termix/plugin-sdk/ddl";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
db: null as unknown,
|
||||
deleted: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
return { pluginLogger: log };
|
||||
});
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => state.db,
|
||||
}));
|
||||
vi.mock("../../../database/db/dialect.js", () => ({
|
||||
resolveDatabaseDialect: () => "sqlite",
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => {
|
||||
const remove = (what: string) => ({
|
||||
deleteByPlugin: async (pluginId: string) => {
|
||||
state.deleted.push(`${what}:${pluginId}`);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
return {
|
||||
createCurrentPluginStorageRepository: () => remove("kv"),
|
||||
createCurrentPluginMigrationRepository: () => remove("migrations"),
|
||||
createCurrentPluginPermissionGrantRepository: () => remove("grants"),
|
||||
createCurrentPluginSettingsRepository: () => remove("settings"),
|
||||
};
|
||||
});
|
||||
|
||||
const { assertOwnedTables, readMigrations } =
|
||||
await import("../../../plugins/migrations.js");
|
||||
const { removePluginData, ownedTableNames } =
|
||||
await import("../../../plugins/data.js");
|
||||
|
||||
describe("the migration checker", () => {
|
||||
it.each([
|
||||
["a comment between keyword and name", "DROP TABLE/**/users"],
|
||||
["a comment with spaces", "DROP TABLE /* x */ users"],
|
||||
["bracket quoting", "DROP TABLE [users]"],
|
||||
["double quoting", 'DROP TABLE "users"'],
|
||||
["back quoting", "DROP TABLE `users`"],
|
||||
["a second name in a list", "DROP TABLE p_foo_a, users"],
|
||||
["a rename out of the namespace", "ALTER TABLE p_foo_a RENAME TO users"],
|
||||
["a MySQL rename in", "RENAME TABLE users TO p_foo_users"],
|
||||
["an insert", "INSERT INTO users (id) VALUES ('x')"],
|
||||
["an update", "UPDATE users SET is_admin = 1"],
|
||||
["a delete", "DELETE FROM sessions"],
|
||||
["a schema name", "DELETE FROM main.users"],
|
||||
["an index on a core table", "CREATE INDEX i ON users (id)"],
|
||||
[
|
||||
"a trigger",
|
||||
"CREATE TRIGGER t AFTER INSERT ON p_foo_a BEGIN SELECT 1; END",
|
||||
],
|
||||
["a view", "CREATE VIEW users_copy AS SELECT * FROM users"],
|
||||
["attaching a database", "ATTACH DATABASE 'x.db' AS other"],
|
||||
["a pragma", "PRAGMA writable_schema = 1"],
|
||||
["a truncate", "TRUNCATE TABLE users"],
|
||||
["an unlogged table", "CREATE UNLOGGED TABLE users (id int)"],
|
||||
["a CTE write", "WITH x AS (SELECT 1) DELETE FROM users"],
|
||||
[
|
||||
"a Postgres dollar-quoted string hiding a statement",
|
||||
"SELECT $$'$$; UPDATE users SET is_admin = true; SELECT $$'$$",
|
||||
],
|
||||
[
|
||||
"a MySQL backslash escape hiding a statement",
|
||||
"SELECT 'a\\''; UPDATE users SET is_admin = 1; SELECT ''",
|
||||
],
|
||||
["dropping a core index by name", "DROP INDEX idx_sessions_user_id"],
|
||||
["altering a core index by name", "ALTER INDEX users_pkey RENAME TO x"],
|
||||
["dropping a core sequence", "DROP SEQUENCE users_id_seq"],
|
||||
["a cascade", "DROP TABLE p_foo_a CASCADE"],
|
||||
[
|
||||
"a MySQL multi-table update",
|
||||
"UPDATE p_foo_a JOIN users ON 1 = 1 SET users.is_admin = 1",
|
||||
],
|
||||
["a MySQL comma update", "UPDATE p_foo_a, users SET users.is_admin = 1"],
|
||||
[
|
||||
"a MySQL multi-table delete",
|
||||
"DELETE p_foo_a, users FROM p_foo_a JOIN users",
|
||||
],
|
||||
[
|
||||
"a delete with USING",
|
||||
"DELETE FROM p_foo_a, users USING p_foo_a JOIN users",
|
||||
],
|
||||
["SELECT INTO", "SELECT * INTO users_copy FROM users"],
|
||||
["writing a file", "SELECT 1 INTO OUTFILE '/tmp/x'"],
|
||||
["reading a server file", "SELECT pg_read_file('/etc/passwd')"],
|
||||
["changing a server setting", "SELECT set_config('x', 'y', false)"],
|
||||
[
|
||||
"running a string as SQL",
|
||||
"SELECT query_to_xml('DELETE FROM users', true, true, '')",
|
||||
],
|
||||
["setval on a core sequence", "SELECT setval('users_id_seq', 1)"],
|
||||
])("refuses %s", (_label, sql) => {
|
||||
expect(findUnownedTableWrites("foo", sql)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["its own table", "CREATE TABLE IF NOT EXISTS p_foo_a (id int)"],
|
||||
[
|
||||
"a reference to a core table",
|
||||
"CREATE TABLE p_foo_a (u text REFERENCES users(id))",
|
||||
],
|
||||
["copying from a core table", "INSERT INTO p_foo_a SELECT id FROM users"],
|
||||
["a column rename", "ALTER TABLE p_foo_a RENAME COLUMN a TO b"],
|
||||
[
|
||||
"a quoted name in a string",
|
||||
"INSERT INTO p_foo_a (x) VALUES ('DROP TABLE users')",
|
||||
],
|
||||
[
|
||||
"dropping an index it created in the same file",
|
||||
"CREATE INDEX idx_foo_a ON p_foo_a (id); DROP INDEX idx_foo_a",
|
||||
],
|
||||
["dropping a prefixed index", "DROP INDEX IF EXISTS p_foo_a_idx"],
|
||||
[
|
||||
"setval on its own sequence",
|
||||
"SELECT setval(pg_get_serial_sequence('p_foo_a', 'id'), 1)",
|
||||
],
|
||||
["its own sequence", "CREATE SEQUENCE IF NOT EXISTS p_foo_seq"],
|
||||
])("allows %s", (_label, sql) => {
|
||||
expect(findUnownedTableWrites("foo", sql)).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows dropping an index an earlier migration created", () => {
|
||||
const owned = collectOwnedIndexes("foo", [
|
||||
"CREATE INDEX idx_foo_a ON p_foo_a (id)",
|
||||
"CREATE INDEX idx_core ON users (id)",
|
||||
]);
|
||||
expect([...owned]).toEqual(["idx_foo_a"]);
|
||||
expect(
|
||||
findUnownedTableWrites("foo", "DROP INDEX idx_foo_a", new Set(), owned),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findUnownedTableWrites("foo", "DROP INDEX idx_core", new Set(), owned),
|
||||
).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("passes every migration a bundled plugin ships", () => {
|
||||
const plugins = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../plugins",
|
||||
);
|
||||
for (const id of fs.readdirSync(plugins)) {
|
||||
const legacy = new Set(
|
||||
Object.entries(LEGACY_TABLE_OWNERS)
|
||||
.filter(([, owner]) => owner === id)
|
||||
.map(([table]) => table),
|
||||
);
|
||||
for (const dialect of ["sqlite", "postgres", "mysql"]) {
|
||||
const dir = path.join(plugins, id, "migrations", dialect);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((file) => file.endsWith(".sql"))
|
||||
.sort();
|
||||
const sqls = files.map((file) =>
|
||||
fs.readFileSync(path.join(dir, file), "utf8"),
|
||||
);
|
||||
// The runner lets a later migration drop an index an earlier one made.
|
||||
const ownedIndexes = collectOwnedIndexes(id, sqls, legacy);
|
||||
files.forEach((file, index) => {
|
||||
expect(
|
||||
findUnownedTableWrites(id, sqls[index], legacy, ownedIndexes),
|
||||
`${id}/${dialect}/${file}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a user plugin no legacy exemption at runtime", () => {
|
||||
const adopt = 'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"';
|
||||
expect(() =>
|
||||
assertOwnedTables("fleets", adopt, { bundled: true }),
|
||||
).not.toThrow();
|
||||
expect(() => assertOwnedTables("fleets", adopt)).toThrow(/fleets/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading migrations", () => {
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
while (roots.length) {
|
||||
fs.rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a migration that is a symlink to a file outside the plugin", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-mig-link-"));
|
||||
roots.push(root);
|
||||
const outside = path.join(root, "evil.sql");
|
||||
fs.writeFileSync(outside, "DROP TABLE users;");
|
||||
const dir = path.join(root, "plugin", "migrations", "sqlite");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(dir, "0001_init.sql"), "file");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
expect(() => readMigrations(path.join(root, "plugin"), "sqlite")).toThrow(
|
||||
/outside the plugin/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removePluginData", () => {
|
||||
let sqlite: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
sqlite = new Database(":memory:");
|
||||
for (const table of [
|
||||
"users",
|
||||
"p_foo_notes",
|
||||
"p_foo_items",
|
||||
"p_foo_bar_x",
|
||||
"p_other_notes",
|
||||
]) {
|
||||
sqlite.exec(`CREATE TABLE "${table}" (id INTEGER PRIMARY KEY)`);
|
||||
}
|
||||
state.db = drizzle(sqlite);
|
||||
state.deleted = [];
|
||||
});
|
||||
|
||||
afterEach(() => sqlite.close());
|
||||
|
||||
const tables = () =>
|
||||
(
|
||||
sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.all() as Array<{ name: string }>
|
||||
)
|
||||
.map((row) => row.name)
|
||||
.sort();
|
||||
|
||||
it("drops only the plugin's own tables, found in the catalog", async () => {
|
||||
const removed = await removePluginData("foo", {
|
||||
knownPluginIds: ["foo", "foo-bar", "other"],
|
||||
});
|
||||
expect(removed.tables.sort()).toEqual(["p_foo_items", "p_foo_notes"]);
|
||||
expect(tables()).toEqual(["p_foo_bar_x", "p_other_notes", "users"]);
|
||||
});
|
||||
|
||||
it("takes its settings and secrets with it", async () => {
|
||||
await removePluginData("foo", { knownPluginIds: ["foo"] });
|
||||
expect(state.deleted).toContain("settings:foo");
|
||||
expect(state.deleted).toContain("kv:foo");
|
||||
expect(state.deleted).toContain("grants:foo");
|
||||
});
|
||||
|
||||
it("leaves a longer prefix another plugin owns", () => {
|
||||
expect(
|
||||
ownedTableNames(
|
||||
"foo",
|
||||
["p_foo_a", "p_foo_bar_b", "p_food_c"],
|
||||
["foo-bar"],
|
||||
),
|
||||
).toEqual(["p_foo_a"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Discovery: a bundled id can never be taken by a user plugin, the folder
|
||||
* name is the id, nothing a manifest names (or a symlink inside the plugin)
|
||||
* reaches outside the plugin folder, table prefixes cannot overlap, and the
|
||||
* manifest validator refuses unknown fields, bad ids and reserved ids.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { PluginLoader, prefixesOverlap } from "../../../plugins/loader.js";
|
||||
import { parseManifest } from "../../../plugins/manifest.js";
|
||||
import { RESERVED_PLUGIN_IDS } from "@termix/plugin-sdk/manifest";
|
||||
import { createFixturePlugin } from "../fixture-plugin.js";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginStorageRepository: () => ({}),
|
||||
}));
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.DATA_DIR;
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
function validManifest(overrides: Record<string, unknown> = {}) {
|
||||
const fixture = createFixturePlugin();
|
||||
cleanups.push(fixture.cleanup);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(fixture.dir, "manifest.json"), "utf8"),
|
||||
);
|
||||
return { ...manifest, ...overrides };
|
||||
}
|
||||
|
||||
describe("shadowing a bundled plugin", () => {
|
||||
it("is refused even when the bundled one failed to load", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
const userPlugins = path.join(user, "plugins");
|
||||
fs.mkdirSync(userPlugins, { recursive: true });
|
||||
|
||||
// The bundled copy is broken, so it never makes it into the loader.
|
||||
createFixturePlugin({ id: "ssh-terminal", root: bundled });
|
||||
fs.writeFileSync(
|
||||
path.join(bundled, "ssh-terminal", "manifest.json"),
|
||||
"{ broken",
|
||||
);
|
||||
createFixturePlugin({ id: "ssh-terminal", root: userPlugins });
|
||||
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
||||
process.env.DATA_DIR = user;
|
||||
const loaded = await new PluginLoader().loadAll();
|
||||
|
||||
expect(loaded).toEqual([]);
|
||||
});
|
||||
|
||||
it("needs the folder name to be the manifest id", async () => {
|
||||
const fixture = createFixturePlugin({
|
||||
manifestOverrides: { id: "other-id" },
|
||||
});
|
||||
cleanups.push(fixture.cleanup);
|
||||
await expect(new PluginLoader().load(fixture.dir, "user")).rejects.toThrow(
|
||||
/does not match manifest id/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("table prefixes", () => {
|
||||
it("overlap when one id's prefix covers the other's tables", () => {
|
||||
expect(prefixesOverlap("foo", "foo-bar")).toBe(true);
|
||||
expect(prefixesOverlap("foo-bar", "foo")).toBe(true);
|
||||
expect(prefixesOverlap("session", "session-sharing")).toBe(true);
|
||||
expect(prefixesOverlap("ai", "automations")).toBe(false);
|
||||
expect(prefixesOverlap("web-endpoint", "webauthn")).toBe(false);
|
||||
expect(prefixesOverlap("foo", "foo")).toBe(false);
|
||||
});
|
||||
|
||||
it("the loader refuses the second plugin of an overlapping pair", async () => {
|
||||
const root = tempRoot("termix-overlap-");
|
||||
createFixturePlugin({ id: "foo", root });
|
||||
createFixturePlugin({ id: "foo-bar", root });
|
||||
const loader = new PluginLoader();
|
||||
await loader.load(path.join(root, "foo"), "bundled");
|
||||
await expect(
|
||||
loader.load(path.join(root, "foo-bar"), "user"),
|
||||
).rejects.toThrow(/overlaps "foo"/);
|
||||
});
|
||||
|
||||
it("no two bundled plugins overlap", () => {
|
||||
const plugins = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../plugins",
|
||||
);
|
||||
const ids = fs
|
||||
.readdirSync(plugins, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
for (const a of ids) {
|
||||
for (const b of ids) {
|
||||
expect(prefixesOverlap(a, b), `${a} and ${b}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("paths stay inside the plugin", () => {
|
||||
it("refuses a backend entry that is a symlink to outside", async () => {
|
||||
const root = tempRoot("termix-link-");
|
||||
const outside = path.join(root, "outside.mjs");
|
||||
fs.writeFileSync(outside, "export function activate() {}");
|
||||
const fixture = createFixturePlugin({ id: "linker", root });
|
||||
const entry = path.join(fixture.dir, "backend", "index.mjs");
|
||||
fs.rmSync(entry);
|
||||
try {
|
||||
fs.symlinkSync(outside, entry, "file");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await expect(new PluginLoader().load(fixture.dir, "user")).rejects.toThrow(
|
||||
/outside the plugin directory/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["backend", "../../evil.js"],
|
||||
["backend", "/etc/evil.js"],
|
||||
["backend", "C:/evil.js"],
|
||||
["frontend", "dist\\..\\..\\evil.js"],
|
||||
["frontend", "."],
|
||||
["locales", "../../secrets"],
|
||||
])("the validator refuses %s: %s", (field, value) => {
|
||||
const { manifest, errors } = parseManifest(
|
||||
validManifest({ [field]: value }),
|
||||
);
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(errors.join("\n")).toMatch(/relative path inside the plugin/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("manifest validation", () => {
|
||||
it.each(RESERVED_PLUGIN_IDS.map((id) => [id]))(
|
||||
"refuses the reserved id %s",
|
||||
(id) => {
|
||||
const { manifest, errors } = parseManifest(validManifest({ id }));
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(errors.join("\n")).toMatch(/reserved/);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([["Bad-Id"], ["1starts-with-digit"], ["has_underscore"], ["a"]])(
|
||||
"refuses the bad id %s",
|
||||
(id) => {
|
||||
expect(parseManifest(validManifest({ id })).manifest).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses an unknown top-level field", () => {
|
||||
const { manifest, errors } = parseManifest(
|
||||
validManifest({ runAsRoot: true }),
|
||||
);
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(errors.join("\n")).toMatch(/runAsRoot/);
|
||||
});
|
||||
|
||||
it("refuses an unknown nested field", () => {
|
||||
const { manifest } = parseManifest(
|
||||
validManifest({ author: { name: "x", backdoor: true } }),
|
||||
);
|
||||
expect(manifest).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a preset key that is not a plain name", () => {
|
||||
const presets = {
|
||||
simple: { __proto__x: true },
|
||||
balanced: { __proto__x: true },
|
||||
advanced: { __proto__x: true },
|
||||
};
|
||||
const { manifest, errors } = parseManifest(
|
||||
validManifest({ contributes: { uiPresets: presets } }),
|
||||
);
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(errors.join("\n")).toMatch(/invalid key/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* A program a plugin starts through ctx.process.run gets a small base
|
||||
* environment, never the server's own, which holds the JWT secret, database
|
||||
* keys and OIDC client secrets.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { baseEnvironment } from "../../../plugins/ctx-process.js";
|
||||
|
||||
describe("ctx.process environment", () => {
|
||||
it("passes the path, home, temp and proxy through", () => {
|
||||
const env = baseEnvironment({
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/home/termix",
|
||||
TMPDIR: "/tmp",
|
||||
HTTPS_PROXY: "http://proxy:3128",
|
||||
});
|
||||
expect(env).toEqual({
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/home/termix",
|
||||
TMPDIR: "/tmp",
|
||||
HTTPS_PROXY: "http://proxy:3128",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops the server's secrets", () => {
|
||||
const env = baseEnvironment({
|
||||
PATH: "/usr/bin",
|
||||
JWT_SECRET: "jwt",
|
||||
DATABASE_KEY: "db",
|
||||
DATABASE_URL: "postgres://user:pass@db/termix",
|
||||
OIDC_CLIENT_SECRET: "oidc",
|
||||
INTERNAL_AUTH_TOKEN: "internal",
|
||||
GUACAMOLE_ENCRYPTION_KEY: "guac",
|
||||
});
|
||||
expect(Object.keys(env)).toEqual(["PATH"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* RBAC: a plugin's permissions live in its own namespace and nowhere else.
|
||||
* A plugin cannot take a core group's name as its id, its own declared names
|
||||
* resolve to itself, and a role default is recorded as applied only once the
|
||||
* role really has it.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
/** roleName -> the permission array stored on that role. */
|
||||
roles: new Map<string, { id: number; permissions: string[] }>(),
|
||||
/** The rbac_applied_defaults rows, as "<role>:<permission>". */
|
||||
appliedDefaults: new Set<string>(),
|
||||
/** The rbac_known_permissions rows. */
|
||||
known: new Set<string>(),
|
||||
warnings: [] as string[],
|
||||
failUpdate: false,
|
||||
}));
|
||||
|
||||
vi.mock("../../../upgrade/boot-migrations.js", () => ({
|
||||
runPluginDataMigrations: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn((message: string) => {
|
||||
state.warnings.push(message);
|
||||
}),
|
||||
error: vi.fn((message: string) => {
|
||||
state.warnings.push(message);
|
||||
}),
|
||||
success: vi.fn(),
|
||||
},
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => {}),
|
||||
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async () => true,
|
||||
invalidateUserPermissionCache: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentRoleRepository: () => ({
|
||||
findRoleByName: async (name: string) => {
|
||||
const role = state.roles.get(name);
|
||||
if (!role) return null;
|
||||
return {
|
||||
id: role.id,
|
||||
name,
|
||||
permissions: JSON.stringify(role.permissions),
|
||||
};
|
||||
},
|
||||
updateRole: async (id: number, updates: { permissions?: string }) => {
|
||||
if (state.failUpdate) throw new Error("database is locked");
|
||||
for (const [name, role] of state.roles) {
|
||||
if (role.id !== id) continue;
|
||||
if (updates.permissions === undefined) continue;
|
||||
state.roles.set(name, {
|
||||
id,
|
||||
permissions: JSON.parse(updates.permissions) as string[],
|
||||
});
|
||||
}
|
||||
},
|
||||
listRoleUserIds: async () => [],
|
||||
listRoles: async () =>
|
||||
[...state.roles.entries()].map(([name, role]) => ({
|
||||
id: role.id,
|
||||
name,
|
||||
permissions: JSON.stringify(role.permissions),
|
||||
})),
|
||||
}),
|
||||
createCurrentRbacPermissionRepository: () => ({
|
||||
listKnown: async () =>
|
||||
[...state.known].map((permission) => ({ permission, pluginId: null })),
|
||||
recordKnown: async (rows: { permission: string }[]) => {
|
||||
for (const row of rows) state.known.add(row.permission);
|
||||
return rows.length;
|
||||
},
|
||||
listAppliedDefaults: async () =>
|
||||
[...state.appliedDefaults].map((key) => {
|
||||
const [roleName, ...rest] = key.split(":");
|
||||
return { roleName, permission: rest.join(":") };
|
||||
}),
|
||||
recordAppliedDefaults: async (
|
||||
rows: { roleName: string; permission: string }[],
|
||||
) => {
|
||||
for (const row of rows) {
|
||||
state.appliedDefaults.add(`${row.roleName}:${row.permission}`);
|
||||
}
|
||||
return rows.length;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { PluginLoader } = await import("../../../plugins/loader.js");
|
||||
const { createFixturePlugin } = await import("../fixture-plugin.js");
|
||||
const { registerPluginPermissions, resetPermissionCatalog, isValidPermission } =
|
||||
await import("../../../utils/permission-catalog.js");
|
||||
const { resolvePermission } = await import("../../../plugins/rbac.js");
|
||||
const { parseManifest } = await import("../../../plugins/manifest.js");
|
||||
|
||||
function permission(name: string, defaultRoles?: string[]) {
|
||||
return {
|
||||
name,
|
||||
titleKey: `permissions.${name}.title`,
|
||||
descriptionKey: `permissions.${name}.description`,
|
||||
...(defaultRoles ? { defaultRoles } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.roles.clear();
|
||||
state.appliedDefaults.clear();
|
||||
state.known.clear();
|
||||
state.warnings.length = 0;
|
||||
state.failUpdate = false;
|
||||
resetPermissionCatalog();
|
||||
});
|
||||
afterEach(() => resetPermissionCatalog());
|
||||
|
||||
describe("a plugin cannot mint core permissions", () => {
|
||||
it.each([["admin"], ["hosts"], ["credentials"]])(
|
||||
"a plugin called %s never loads",
|
||||
async (id) => {
|
||||
const fixture = createFixturePlugin({
|
||||
id,
|
||||
capabilities: ["kv:own"],
|
||||
manifestOverrides: {
|
||||
contributes: { permissions: [permission("backdoor", ["user"])] },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
new PluginLoader().load(fixture.dir, "user"),
|
||||
).rejects.toThrow(/reserved/);
|
||||
expect(isValidPermission(`${id}.backdoor`)).toBe(false);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("resolving a permission name", () => {
|
||||
it("a name the plugin declares is its own, whatever its first segment", () => {
|
||||
registerPluginPermissions({
|
||||
group: "billing",
|
||||
pluginId: "billing",
|
||||
label: "Billing",
|
||||
permissions: ["billing.pay"],
|
||||
});
|
||||
const parsed = parseManifest({
|
||||
id: "sample",
|
||||
name: "Sample",
|
||||
version: "1.0.0",
|
||||
description: "A fixture.",
|
||||
author: { name: "Termix" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: ["kv:own"],
|
||||
contributes: { permissions: [permission("billing.pay")] },
|
||||
}).manifest!;
|
||||
|
||||
expect(resolvePermission(parsed, "billing.pay")).toBe("sample.billing.pay");
|
||||
});
|
||||
});
|
||||
|
||||
describe("role defaults", () => {
|
||||
it("are not marked applied when the role update fails, so the next boot retries", async () => {
|
||||
state.roles.set("user", { id: 2, permissions: ["hosts.*"] });
|
||||
state.failUpdate = true;
|
||||
const fixture = createFixturePlugin({
|
||||
id: "docker",
|
||||
capabilities: ["kv:own"],
|
||||
manifestOverrides: {
|
||||
category: "Infrastructure",
|
||||
contributes: { permissions: [permission("run", ["user"])] },
|
||||
},
|
||||
});
|
||||
const { resetPluginRuntime, getPluginRuntime, activatePlugin } =
|
||||
await import("../../../plugins/index.js");
|
||||
resetPluginRuntime();
|
||||
const loader = getPluginRuntime().loader;
|
||||
try {
|
||||
const plugin = await loader.load(fixture.dir);
|
||||
await activatePlugin(plugin.id);
|
||||
expect(state.roles.get("user")?.permissions).not.toContain("docker.run");
|
||||
expect(state.appliedDefaults.has("user:docker.run")).toBe(false);
|
||||
|
||||
// Next boot, the database is fine again.
|
||||
await loader.shutdown();
|
||||
state.failUpdate = false;
|
||||
resetPluginRuntime();
|
||||
const again = getPluginRuntime().loader;
|
||||
await activatePlugin((await again.load(fixture.dir)).id);
|
||||
expect(state.roles.get("user")?.permissions).toContain("docker.run");
|
||||
expect(state.appliedDefaults.has("user:docker.run")).toBe(true);
|
||||
await again.shutdown();
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("only ever land in the plugin's own namespace", async () => {
|
||||
state.roles.set("user", { id: 2, permissions: [] });
|
||||
const fixture = createFixturePlugin({
|
||||
id: "docker",
|
||||
capabilities: ["kv:own"],
|
||||
manifestOverrides: {
|
||||
category: "Infrastructure",
|
||||
contributes: { permissions: [permission("run", ["user"])] },
|
||||
},
|
||||
});
|
||||
const { resetPluginRuntime, getPluginRuntime, activatePlugin } =
|
||||
await import("../../../plugins/index.js");
|
||||
resetPluginRuntime();
|
||||
const loader = getPluginRuntime().loader;
|
||||
try {
|
||||
await activatePlugin((await loader.load(fixture.dir)).id);
|
||||
expect(state.roles.get("user")?.permissions).toEqual(["docker.run"]);
|
||||
} finally {
|
||||
await loader.shutdown();
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* /plugin-api and /plugin-ws: auth unless declared public, 503 for an
|
||||
* installed plugin that is off, no stack in an error, public routes listed
|
||||
* for admins, and an anonymous caller who cannot spend a plugin's error
|
||||
* budget.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import httpModule from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
validToken: "valid-token",
|
||||
userId: "user-1",
|
||||
installed: new Set<string>(),
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
};
|
||||
return { pluginLogger: log, databaseLogger: log };
|
||||
});
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
const header = req.headers.authorization;
|
||||
if (header !== `Bearer ${state.validToken}`) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
(req as express.Request & { userId?: string }).userId = state.userId;
|
||||
next();
|
||||
},
|
||||
verifyJWTToken: async (token: string) =>
|
||||
token === state.validToken ? { userId: state.userId } : null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getUserDataKey: () => Buffer.from("key") },
|
||||
}));
|
||||
vi.mock("../../../plugins/permissions.js", () => ({
|
||||
hasCapability: async () => true,
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
state.audits.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
const http = await import("../../../plugins/http.js");
|
||||
const ws = await import("../../../plugins/ws.js");
|
||||
const { getActor } = await import("../../../plugins/actor.js");
|
||||
const { mountPluginApi } =
|
||||
await import("../../../database/routes/plugin-api-routes.js");
|
||||
|
||||
const manifest = {
|
||||
id: "sample",
|
||||
name: "Sample",
|
||||
version: "1.0.0",
|
||||
capabilities: ["network:serve"],
|
||||
} as never;
|
||||
|
||||
let server: Server | null = null;
|
||||
|
||||
async function start(): Promise<{ httpBase: string; wsBase: string }> {
|
||||
const app = express();
|
||||
mountPluginApi(app);
|
||||
const instance = httpModule.createServer(app);
|
||||
ws.attachPluginWebSockets(instance);
|
||||
server = instance;
|
||||
return new Promise((resolve) => {
|
||||
instance.listen(0, "127.0.0.1", () => {
|
||||
const { port } = instance.address() as AddressInfo;
|
||||
resolve({
|
||||
httpBase: `http://127.0.0.1:${port}`,
|
||||
wsBase: `ws://127.0.0.1:${port}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function upgrade(
|
||||
url: string,
|
||||
protocols: string[] = [],
|
||||
): Promise<"open" | number> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new WebSocket(url, protocols);
|
||||
socket.once("open", () => {
|
||||
socket.close();
|
||||
resolve("open");
|
||||
});
|
||||
socket.once("unexpected-response", (_req, res) =>
|
||||
resolve(res.statusCode ?? 0),
|
||||
);
|
||||
socket.once("error", () => resolve(0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
http.resetPluginHttp();
|
||||
ws.resetPluginWebSockets();
|
||||
state.installed = new Set();
|
||||
state.audits = [];
|
||||
http.setPluginInstalledCheck((id) => state.installed.has(id));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
ws.resetPluginWebSockets();
|
||||
if (server) {
|
||||
await new Promise((resolve) => server!.close(() => resolve(undefined)));
|
||||
server = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("auth by default", () => {
|
||||
it("refuses an HTTP route and a socket without a login", async () => {
|
||||
const router = http.createPluginRouter({ manifest, reportError: () => {} });
|
||||
router.get("/private", (_req, res) => res.json({ ok: true }));
|
||||
ws.registerPluginWsRoute("sample", "/socket", () => {}, ["network:serve"]);
|
||||
const { httpBase, wsBase } = await start();
|
||||
|
||||
expect((await fetch(`${httpBase}/plugin-api/sample/private`)).status).toBe(
|
||||
401,
|
||||
);
|
||||
expect(await upgrade(`${wsBase}/plugin-ws/sample/socket`)).toBe(401);
|
||||
});
|
||||
|
||||
it("gives a public socket's guest no actor at all", async () => {
|
||||
let actor: string | undefined = "unset";
|
||||
ws.registerPluginWsRoute(
|
||||
"sample",
|
||||
"/guest",
|
||||
({ socket }) => {
|
||||
actor = getActor();
|
||||
(socket as WebSocket).close();
|
||||
},
|
||||
["network:serve"],
|
||||
{ public: true },
|
||||
);
|
||||
const { wsBase } = await start();
|
||||
await upgrade(`${wsBase}/plugin-ws/sample/guest`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(actor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a disabled plugin", () => {
|
||||
it("answers 503 on HTTP and on a socket once its routes are gone", async () => {
|
||||
state.installed.add("sample");
|
||||
const { httpBase, wsBase } = await start();
|
||||
|
||||
const response = await fetch(`${httpBase}/plugin-api/sample/anything`);
|
||||
expect(response.status).toBe(503);
|
||||
expect(await upgrade(`${wsBase}/plugin-ws/sample/socket`)).toBe(503);
|
||||
});
|
||||
|
||||
it("answers 404 for an id nobody installed", async () => {
|
||||
const { httpBase, wsBase } = await start();
|
||||
expect((await fetch(`${httpBase}/plugin-api/ghost/x`)).status).toBe(404);
|
||||
expect(await upgrade(`${wsBase}/plugin-ws/ghost/socket`)).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("errors", () => {
|
||||
it("hides the stack and the message", async () => {
|
||||
const router = http.createPluginRouter({ manifest, reportError: () => {} });
|
||||
router.get("/boom", () => {
|
||||
throw new Error("secret detail at /srv/termix/keys");
|
||||
});
|
||||
const { httpBase } = await start();
|
||||
const response = await fetch(`${httpBase}/plugin-api/sample/boom`, {
|
||||
headers: { Authorization: `Bearer ${state.validToken}` },
|
||||
});
|
||||
const body = await response.text();
|
||||
expect(response.status).toBe(500);
|
||||
expect(body).not.toMatch(/secret detail|at \//);
|
||||
});
|
||||
|
||||
it("does not let an anonymous caller spend the error budget", async () => {
|
||||
const reported: unknown[] = [];
|
||||
const router = http.createPluginRouter({
|
||||
manifest,
|
||||
options: { public: ["/hook", "/throws"] },
|
||||
reportError: (error) => reported.push(error),
|
||||
});
|
||||
router.post("/hook", (_req, res) => res.json({ ok: true }));
|
||||
router.get("/throws", () => {
|
||||
throw new Error("bad input");
|
||||
});
|
||||
const { httpBase } = await start();
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const junk = await fetch(`${httpBase}/plugin-api/sample/hook`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{not json",
|
||||
});
|
||||
expect(junk.status).toBe(400);
|
||||
await fetch(`${httpBase}/plugin-api/sample/throws`);
|
||||
}
|
||||
expect(reported).toEqual([]);
|
||||
});
|
||||
|
||||
it("still counts a signed-in caller's server error", async () => {
|
||||
const reported: unknown[] = [];
|
||||
const router = http.createPluginRouter({
|
||||
manifest,
|
||||
reportError: (error) => reported.push(error),
|
||||
});
|
||||
router.get("/boom", () => {
|
||||
throw new Error("real bug");
|
||||
});
|
||||
const { httpBase } = await start();
|
||||
await fetch(`${httpBase}/plugin-api/sample/boom`, {
|
||||
headers: { Authorization: `Bearer ${state.validToken}` },
|
||||
});
|
||||
expect(reported).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("public routes are visible", () => {
|
||||
it("lists them for the admin details and audits a public socket", async () => {
|
||||
http.createPluginRouter({
|
||||
manifest,
|
||||
options: { public: ["/callback", "/u/:handle"] },
|
||||
reportError: () => {},
|
||||
});
|
||||
ws.registerPluginWsRoute("sample", "/guest", () => {}, ["network:serve"], {
|
||||
public: true,
|
||||
});
|
||||
ws.registerPluginWsRoute("sample", "/private", () => {}, ["network:serve"]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(http.getPluginPublicHttpRoutes("sample")).toEqual([
|
||||
"/callback",
|
||||
"/u/:handle",
|
||||
]);
|
||||
expect(ws.getPluginPublicWsRoutes("sample")).toEqual(["/guest"]);
|
||||
expect(
|
||||
state.audits.some(
|
||||
(entry) =>
|
||||
entry.action === "plugin_ws_public_route" &&
|
||||
entry.resourceId === "sample",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
http.unregisterPluginHttp("sample");
|
||||
expect(http.getPluginPublicHttpRoutes("sample")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Settings: a secret is encrypted at rest and never read back, and every
|
||||
* write path goes through the same checks, including the host import path
|
||||
* that used to write rows straight into plugin_settings.
|
||||
*
|
||||
* The HTTP scope checks (admin needs admin.plugins.manage, a user only ever
|
||||
* writes their own, a host needs edit access) are covered route by route in
|
||||
* settings-routes.test.ts.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: [] as Row[],
|
||||
manifests: [] as unknown[],
|
||||
normalizer: null as null | ((raw: Record<string, unknown>) => unknown),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
return { sshLogger: log, pluginLogger: log };
|
||||
});
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (p: string, s: string, id: string | null, k: string) =>
|
||||
state.rows.find(
|
||||
(row) =>
|
||||
row.pluginId === p &&
|
||||
row.scope === s &&
|
||||
row.scopeId === id &&
|
||||
row.key === k,
|
||||
) ?? null,
|
||||
getAll: async (p: string, s: string, id: string | null) =>
|
||||
state.rows.filter(
|
||||
(row) => row.pluginId === p && row.scope === s && row.scopeId === id,
|
||||
),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
state.rows = state.rows.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key
|
||||
),
|
||||
);
|
||||
state.rows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../utils/system-secret-crypto.js", () => ({
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||
encryptSystemSecret: async (plaintext: string) =>
|
||||
`sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
||||
decryptSystemSecret: async (stored: string) =>
|
||||
Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString("utf8"),
|
||||
}));
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({
|
||||
loader: {
|
||||
list: () =>
|
||||
state.manifests.map((manifest) => ({ state: "active", manifest })),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../plugins/registry.js", () => ({
|
||||
consume: () => state.normalizer,
|
||||
}));
|
||||
|
||||
const { applyPluginHostImportSettings } =
|
||||
await import("../../../database/routes/host-plugin-settings.js");
|
||||
const { getAllSettings, setSetting } =
|
||||
await import("../../../plugins/settings.js");
|
||||
|
||||
const MANIFEST = {
|
||||
id: "vaultish",
|
||||
name: "Vaultish",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [
|
||||
{ key: "token", type: "secret", labelKey: "k" },
|
||||
{ key: "port", type: "number", labelKey: "k", min: 1, max: 65535 },
|
||||
],
|
||||
},
|
||||
admin: [{ key: "apiKey", type: "secret", labelKey: "k" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.rows = [];
|
||||
state.manifests = [MANIFEST];
|
||||
state.normalizer = null;
|
||||
});
|
||||
|
||||
describe("secrets at rest", () => {
|
||||
it("are stored encrypted and read back only as { set }", async () => {
|
||||
await setSetting(MANIFEST as never, "admin", null, "apiKey", "hunter2");
|
||||
const row = state.rows.find((entry) => entry.key === "apiKey")!;
|
||||
expect(row.encrypted).toBe(true);
|
||||
expect(row.value).not.toContain("hunter2");
|
||||
|
||||
const shown = await getAllSettings(MANIFEST as never, "admin", null, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
expect(shown.apiKey).toEqual({ set: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the host import path", () => {
|
||||
it("encrypts a secret a plugin's normalizer returns", async () => {
|
||||
state.normalizer = () => ({ token: "from-import", port: 8200 });
|
||||
await applyPluginHostImportSettings(7, {});
|
||||
|
||||
const token = state.rows.find((entry) => entry.key === "token")!;
|
||||
expect(token.encrypted).toBe(true);
|
||||
expect(token.value).not.toContain("from-import");
|
||||
expect(state.rows.find((entry) => entry.key === "port")?.value).toBe(
|
||||
"8200",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a key the manifest does not declare, and a bad value", async () => {
|
||||
state.normalizer = () => ({ injected: "x", port: 999999 });
|
||||
await applyPluginHostImportSettings(7, {});
|
||||
expect(state.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("never restores a secret carried in an export", async () => {
|
||||
await applyPluginHostImportSettings(7, {
|
||||
pluginSettings: { vaultish: { token: "leaked", port: 22 } },
|
||||
});
|
||||
expect(state.rows.map((entry) => entry.key)).toEqual(["port"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Two real plugins on disk, one providing a service and one requiring it,
|
||||
* driven through the actual PluginLoader.
|
||||
*
|
||||
* The service ("testplugin.greet") and the permission gating it
|
||||
* ("testplugin.greet.use") are fixture-owned, so nothing about the shipped
|
||||
* plugins is under test here even though the fixtures borrow their ids.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Fixture } from "./fixture-plugin.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
/** Permissions the acting user currently holds. Mutated mid-test. */
|
||||
granted: new Set<string>(),
|
||||
auditEntries: [] as Record<string, unknown>[],
|
||||
/** Records every delegated call, to prove the gate runs before the provider. */
|
||||
providerCalls: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async (params: Record<string, unknown>) => {
|
||||
state.auditEntries.push(params);
|
||||
}),
|
||||
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
state.granted.has(permission),
|
||||
invalidateUserPermissionCache: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||
const {
|
||||
clearServiceRegistry,
|
||||
getRegistration,
|
||||
PluginServicePermissionError,
|
||||
PluginServiceUnavailableError,
|
||||
} = await import("../../plugins/service-registry.js");
|
||||
const { pluginEvents } = await import("../../plugins/events.js");
|
||||
const { createFixturePlugin } = await import("./fixture-plugin.js");
|
||||
|
||||
const SERVICE = "testplugin.greet";
|
||||
// Registered as <pluginId>.<name>, so it carries the provider fixture id.
|
||||
const PERMISSION = "ssh-terminal.greet.use";
|
||||
|
||||
/**
|
||||
* The provider publishes a greet service and records every delegated call to
|
||||
* a file, so the test can prove a denied call never reached it. A module-level
|
||||
* variable would not work: the plugin is imported by URL into this process and
|
||||
* lives in its own ESM module instance.
|
||||
*/
|
||||
function providerSource(callLog: string): string {
|
||||
const literal = JSON.stringify(callLog);
|
||||
return `
|
||||
import fs from "node:fs";
|
||||
export async function activate(ctx) {
|
||||
ctx.services.provide(${JSON.stringify(SERVICE)}, {
|
||||
hello: async (name) => {
|
||||
fs.appendFileSync(${literal}, name + "\\n");
|
||||
return "hello " + name;
|
||||
},
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The consumer takes a handle at activation and stashes it on globalThis so
|
||||
* the test can call it as a specific user afterwards.
|
||||
*/
|
||||
const CONSUMER_SOURCE = `
|
||||
export async function activate(ctx) {
|
||||
globalThis.__consumerHandle = ctx.services.get(${JSON.stringify(SERVICE)});
|
||||
}
|
||||
`;
|
||||
|
||||
function providerFixture(): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: "ssh-terminal",
|
||||
capabilities: ["kv:own"],
|
||||
manifestOverrides: {
|
||||
category: "Terminal",
|
||||
provides: [
|
||||
{ service: SERVICE, version: "1.2.0", permission: PERMISSION },
|
||||
],
|
||||
contributes: {
|
||||
permissions: [
|
||||
{
|
||||
name: "greet.use",
|
||||
titleKey: "permissions.greet.use.title",
|
||||
descriptionKey: "permissions.greet.use.description",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function consumerFixture(
|
||||
requires: Array<Record<string, unknown>> = [
|
||||
{ service: SERVICE, versionRange: "^1.2.0" },
|
||||
],
|
||||
): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: "docker",
|
||||
capabilities: ["kv:own", "users:impersonate"],
|
||||
backendSource: CONSUMER_SOURCE,
|
||||
manifestOverrides: { category: "Infrastructure", requires },
|
||||
});
|
||||
}
|
||||
|
||||
type GreetHandle = {
|
||||
hello: (name: string) => Promise<string>;
|
||||
asUser: (userId: string) => { hello: (name: string) => Promise<string> };
|
||||
};
|
||||
|
||||
function handle(): GreetHandle {
|
||||
return (globalThis as Record<string, unknown>)
|
||||
.__consumerHandle as GreetHandle;
|
||||
}
|
||||
|
||||
describe("plugin service contracts", () => {
|
||||
let provider: Fixture | null = null;
|
||||
let consumer: Fixture | null = null;
|
||||
let loader: InstanceType<typeof PluginLoader> | null = null;
|
||||
let callLog = "";
|
||||
|
||||
beforeEach(() => {
|
||||
state.granted = new Set([PERMISSION]);
|
||||
state.auditEntries = [];
|
||||
state.providerCalls = [];
|
||||
clearServiceRegistry();
|
||||
pluginEvents.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await loader?.shutdown();
|
||||
loader = null;
|
||||
provider?.cleanup();
|
||||
consumer?.cleanup();
|
||||
provider = null;
|
||||
consumer = null;
|
||||
delete (globalThis as Record<string, unknown>).__consumerHandle;
|
||||
clearServiceRegistry();
|
||||
pluginEvents.clear();
|
||||
});
|
||||
|
||||
async function activateBoth(
|
||||
consumerRequires?: Array<Record<string, unknown>>,
|
||||
) {
|
||||
provider = providerFixture();
|
||||
callLog = path.join(provider.root, "calls.txt");
|
||||
fs.writeFileSync(
|
||||
path.join(provider.dir, "backend", "index.mjs"),
|
||||
providerSource(callLog),
|
||||
);
|
||||
|
||||
consumer = consumerRequires
|
||||
? consumerFixture(consumerRequires)
|
||||
: consumerFixture();
|
||||
|
||||
loader = new PluginLoader();
|
||||
const providerPlugin = await loader.load(provider.dir);
|
||||
await loader.activate(providerPlugin.id);
|
||||
|
||||
const consumerPlugin = await loader.load(consumer.dir);
|
||||
await loader.activate(consumerPlugin.id);
|
||||
|
||||
return { providerPlugin, consumerPlugin };
|
||||
}
|
||||
|
||||
function providerCallCount(): number {
|
||||
if (!fs.existsSync(callLog)) return 0;
|
||||
return fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean)
|
||||
.length;
|
||||
}
|
||||
|
||||
it("resolves structurally when both plugins are active", async () => {
|
||||
const { providerPlugin, consumerPlugin } = await activateBoth();
|
||||
|
||||
expect(providerPlugin.state).toBe("active");
|
||||
expect(consumerPlugin.state).toBe("active");
|
||||
|
||||
const registration = getRegistration(SERVICE);
|
||||
expect(registration?.version).toBe("1.2.0");
|
||||
expect(registration?.pluginId).toBe("ssh-terminal");
|
||||
expect(registration?.permission).toBe(PERMISSION);
|
||||
});
|
||||
|
||||
it("fails activation when a hard requirement is unsatisfied", async () => {
|
||||
consumer = consumerFixture([{ service: SERVICE, versionRange: "^9.0.0" }]);
|
||||
|
||||
loader = new PluginLoader();
|
||||
const plugin = await loader.load(consumer.dir);
|
||||
|
||||
await expect(loader.activate(plugin.id)).rejects.toThrow(
|
||||
/no active plugin provides|provides 1\.2\.0/,
|
||||
);
|
||||
expect(plugin.state).toBe("failed");
|
||||
});
|
||||
|
||||
it("activates anyway when an unsatisfied requirement is optional", async () => {
|
||||
consumer = consumerFixture([
|
||||
{ service: SERVICE, versionRange: "^1.2.0", optional: true },
|
||||
{ service: "testplugin.absent", versionRange: "^1.0.0", optional: true },
|
||||
]);
|
||||
|
||||
loader = new PluginLoader();
|
||||
const plugin = await loader.load(consumer.dir);
|
||||
await loader.activate(plugin.id);
|
||||
|
||||
expect(plugin.state).toBe("active");
|
||||
});
|
||||
|
||||
it("allows a call from a user who holds the permission", async () => {
|
||||
await activateBoth();
|
||||
|
||||
await expect(handle().asUser("alice").hello("world")).resolves.toBe(
|
||||
"hello world",
|
||||
);
|
||||
expect(providerCallCount()).toBe(1);
|
||||
|
||||
const entry = state.auditEntries.at(-1);
|
||||
expect(entry?.success).toBe(true);
|
||||
expect(entry?.username).toBe("plugin:docker");
|
||||
expect(entry?.userId).toBe("alice");
|
||||
expect(entry?.action).toBe("plugin_service_testplugin_greet_hello");
|
||||
expect(entry?.resourceId).toBe("ssh-terminal");
|
||||
});
|
||||
|
||||
it("denies a user without the permission, with the standard shape", async () => {
|
||||
await activateBoth();
|
||||
state.granted.delete(PERMISSION);
|
||||
|
||||
await handle()
|
||||
.asUser("mallory")
|
||||
.hello("world")
|
||||
.then(
|
||||
() => expect.unreachable("should have been denied"),
|
||||
(error: InstanceType<typeof PluginServicePermissionError>) => {
|
||||
expect(error.name).toBe("PluginServicePermissionError");
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.body).toEqual({
|
||||
error: "Insufficient permissions",
|
||||
required: PERMISSION,
|
||||
});
|
||||
expect(error.message).toContain("do not have access");
|
||||
},
|
||||
);
|
||||
|
||||
// The gate runs before delegation: the provider never saw the call.
|
||||
expect(providerCallCount()).toBe(0);
|
||||
|
||||
const entry = state.auditEntries.at(-1);
|
||||
expect(entry?.success).toBe(false);
|
||||
expect(entry?.userId).toBe("mallory");
|
||||
expect(String(entry?.errorMessage)).toContain(PERMISSION);
|
||||
});
|
||||
|
||||
it("honours a revoke mid-session on the very next call", async () => {
|
||||
await activateBoth();
|
||||
const live = handle().asUser("alice");
|
||||
|
||||
await expect(live.hello("first")).resolves.toBe("hello first");
|
||||
|
||||
// No restart, no reinstall, same handle: the check is per call, which is
|
||||
// exactly why it lives in the proxy rather than at handle creation.
|
||||
state.granted.delete(PERMISSION);
|
||||
|
||||
await expect(live.hello("second")).rejects.toThrow(
|
||||
PluginServicePermissionError,
|
||||
);
|
||||
expect(providerCallCount()).toBe(1);
|
||||
|
||||
// And granting it back takes effect just as immediately.
|
||||
state.granted.add(PERMISSION);
|
||||
await expect(live.hello("third")).resolves.toBe("hello third");
|
||||
expect(providerCallCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("surfaces a clear error once the provider is deactivated, without tearing the consumer down", async () => {
|
||||
const { consumerPlugin } = await activateBoth();
|
||||
const live = handle().asUser("alice");
|
||||
|
||||
await expect(live.hello("before")).resolves.toBe("hello before");
|
||||
|
||||
await loader!.deactivate("ssh-terminal");
|
||||
|
||||
expect(getRegistration(SERVICE)).toBeUndefined();
|
||||
|
||||
// Documented behaviour: the consumer stays active and decides for itself
|
||||
// what to do. Deactivating one plugin must not silently stop others.
|
||||
expect(consumerPlugin.state).toBe("active");
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await live.hello("after");
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(PluginServiceUnavailableError);
|
||||
expect((thrown as { code: string }).code).toBe("EPLUGINSVCGONE");
|
||||
expect((thrown as Error).message).toContain(SERVICE);
|
||||
expect(providerCallCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses to provide a service the manifest never declared", async () => {
|
||||
provider = createFixturePlugin({
|
||||
id: "ssh-terminal",
|
||||
capabilities: ["kv:own"],
|
||||
backendSource: `
|
||||
export async function activate(ctx) {
|
||||
ctx.services.provide("testplugin.undeclared", { hello: async () => "hi" });
|
||||
}
|
||||
`,
|
||||
manifestOverrides: { category: "Terminal" },
|
||||
});
|
||||
|
||||
loader = new PluginLoader();
|
||||
const plugin = await loader.load(provider.dir);
|
||||
|
||||
await expect(loader.activate(plugin.id)).rejects.toThrow(
|
||||
/not declared in the manifest's provides array/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Unit coverage for the declared service registry: registration, versioned
|
||||
* structural resolution, and the per-call permission gate on a handle.
|
||||
*
|
||||
* The two-fixture end-to-end test lives in service-contract.test.ts.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
clearServiceRegistry,
|
||||
createServiceHandle,
|
||||
getRegistration,
|
||||
getServiceImplementation,
|
||||
listProviderNames,
|
||||
listServices,
|
||||
provideService,
|
||||
resolveRequirements,
|
||||
revokeService,
|
||||
PluginServiceActorError,
|
||||
PluginServicePermissionError,
|
||||
PluginServiceUnavailableError,
|
||||
} = await import("../../plugins/service-registry.js");
|
||||
|
||||
type Registration = ReturnType<typeof provideService>;
|
||||
|
||||
function register(
|
||||
overrides: Partial<Parameters<typeof provideService>[0]> = {},
|
||||
): Registration {
|
||||
return provideService({
|
||||
service: "testplugin.greet",
|
||||
version: "1.0.0",
|
||||
permission: "testplugin.greet.use",
|
||||
pluginId: "provider-plugin",
|
||||
pluginName: "Provider Plugin",
|
||||
implementation: { hello: async (name: string) => `hello ${name}` },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function manifestRequiring(
|
||||
requires: Array<{
|
||||
service: string;
|
||||
versionRange: string;
|
||||
optional?: boolean;
|
||||
}>,
|
||||
) {
|
||||
return { id: "consumer-plugin", requires } as never;
|
||||
}
|
||||
|
||||
describe("service registry", () => {
|
||||
beforeEach(() => clearServiceRegistry());
|
||||
afterEach(() => clearServiceRegistry());
|
||||
|
||||
it("round-trips a registration", () => {
|
||||
const registration = register();
|
||||
|
||||
expect(getRegistration("testplugin.greet")).toBe(registration);
|
||||
expect(listServices()).toHaveLength(1);
|
||||
expect(registration.generation).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("only revokes the registration that is actually current", () => {
|
||||
const first = register();
|
||||
const second = register();
|
||||
|
||||
// A crashed-and-restarted provider must not revoke the replacement its own
|
||||
// restart installed.
|
||||
expect(revokeService("testplugin.greet", first)).toBe(false);
|
||||
expect(getRegistration("testplugin.greet")).toBe(second);
|
||||
|
||||
expect(revokeService("testplugin.greet", second)).toBe(true);
|
||||
expect(getRegistration("testplugin.greet")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a revoke of something never registered", () => {
|
||||
expect(revokeService("nothing.here")).toBe(false);
|
||||
});
|
||||
|
||||
it("hands core the implementation of a compatible version, or nothing", () => {
|
||||
expect(
|
||||
getServiceImplementation("testplugin.greet", "^1.0.0"),
|
||||
).toBeUndefined();
|
||||
const registration = register({ version: "1.4.0" });
|
||||
expect(getServiceImplementation("testplugin.greet", "^1.0.0")).toBe(
|
||||
registration.implementation,
|
||||
);
|
||||
expect(
|
||||
getServiceImplementation("testplugin.greet", "^2.0.0"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("named providers", () => {
|
||||
it("keeps several providers of one service side by side", () => {
|
||||
const ssh = register({
|
||||
name: "ssh",
|
||||
implementation: { kind: async () => "ssh" },
|
||||
});
|
||||
const rdp = register({
|
||||
name: "rdp",
|
||||
pluginId: "other-plugin",
|
||||
implementation: { kind: async () => "rdp" },
|
||||
});
|
||||
|
||||
expect(listProviderNames("testplugin.greet").sort()).toEqual([
|
||||
"rdp",
|
||||
"ssh",
|
||||
]);
|
||||
expect(getRegistration("testplugin.greet", "ssh")).toBe(ssh);
|
||||
expect(getRegistration("testplugin.greet", "rdp")).toBe(rdp);
|
||||
expect(getRegistration("testplugin.greet")).toBeUndefined();
|
||||
expect(listServices()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hands core a named implementation only by its name", () => {
|
||||
const ssh = register({ name: "ssh" });
|
||||
expect(
|
||||
getServiceImplementation("testplugin.greet", "^1.0.0", "ssh"),
|
||||
).toBe(ssh.implementation);
|
||||
expect(
|
||||
getServiceImplementation("testplugin.greet", "^1.0.0"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("revokes one provider and leaves the other", () => {
|
||||
const ssh = register({ name: "ssh" });
|
||||
const rdp = register({ name: "rdp" });
|
||||
|
||||
expect(revokeService("testplugin.greet", ssh)).toBe(true);
|
||||
expect(getRegistration("testplugin.greet", "ssh")).toBeUndefined();
|
||||
expect(getRegistration("testplugin.greet", "rdp")).toBe(rdp);
|
||||
});
|
||||
|
||||
it("routes a named handle to its provider and fails typed once it goes", async () => {
|
||||
const ssh = register({
|
||||
name: "ssh",
|
||||
implementation: { hello: async () => "from ssh" },
|
||||
});
|
||||
register({
|
||||
name: "rdp",
|
||||
implementation: { hello: async () => "from rdp" },
|
||||
});
|
||||
const context = {
|
||||
resolveUserId: () => "user-1",
|
||||
hasPermission: async () => true,
|
||||
audit: () => {},
|
||||
};
|
||||
const handle = createServiceHandle<{ hello: () => Promise<string> }>(
|
||||
"testplugin.greet",
|
||||
"consumer-plugin",
|
||||
context,
|
||||
"ssh",
|
||||
);
|
||||
const unnamed = createServiceHandle<{ hello?: () => Promise<string> }>(
|
||||
"testplugin.greet",
|
||||
"consumer-plugin",
|
||||
context,
|
||||
);
|
||||
|
||||
await expect(handle.hello()).resolves.toBe("from ssh");
|
||||
expect("hello" in unnamed).toBe(false);
|
||||
|
||||
revokeService("testplugin.greet", ssh);
|
||||
expect(() => handle.hello()).toThrow(PluginServiceUnavailableError);
|
||||
});
|
||||
|
||||
it("satisfies a requirement with any compatible provider", () => {
|
||||
register({ name: "rdp", version: "2.0.0" });
|
||||
register({ name: "ssh", version: "1.2.0" });
|
||||
|
||||
expect(
|
||||
resolveRequirements(
|
||||
manifestRequiring([
|
||||
{ service: "testplugin.greet", versionRange: "^1.0.0" },
|
||||
]),
|
||||
).satisfied,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolution", () => {
|
||||
it("is satisfied when the version is in range", () => {
|
||||
register({ version: "1.2.0" });
|
||||
|
||||
const resolution = resolveRequirements(
|
||||
manifestRequiring([
|
||||
{ service: "testplugin.greet", versionRange: "^1.2.0" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(resolution.satisfied).toBe(true);
|
||||
expect(resolution.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails a hard requirement nothing provides", () => {
|
||||
const resolution = resolveRequirements(
|
||||
manifestRequiring([
|
||||
{ service: "testplugin.greet", versionRange: "^1.0.0" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(resolution.satisfied).toBe(false);
|
||||
expect(resolution.errors[0]).toContain("no active plugin provides");
|
||||
});
|
||||
|
||||
it("fails when the provided version is out of range", () => {
|
||||
register({ version: "2.0.0" });
|
||||
|
||||
const resolution = resolveRequirements(
|
||||
manifestRequiring([
|
||||
{ service: "testplugin.greet", versionRange: "^1.0.0" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(resolution.satisfied).toBe(false);
|
||||
expect(resolution.errors[0]).toContain("provides 2.0.0");
|
||||
});
|
||||
|
||||
it("skips an unsatisfied optional requirement", () => {
|
||||
const resolution = resolveRequirements(
|
||||
manifestRequiring([
|
||||
{
|
||||
service: "testplugin.greet",
|
||||
versionRange: "^1.0.0",
|
||||
optional: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(resolution.satisfied).toBe(true);
|
||||
expect(resolution.missingOptional).toEqual(["testplugin.greet"]);
|
||||
});
|
||||
|
||||
it("treats a manifest with no requires as satisfied", () => {
|
||||
expect(resolveRequirements({ id: "x" } as never).satisfied).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handle", () => {
|
||||
function handleFor(allowed: boolean, ...actor: [] | [string | undefined]) {
|
||||
const userId = actor.length === 0 ? "user-1" : actor[0];
|
||||
const audits: Array<Record<string, unknown>> = [];
|
||||
const handle = createServiceHandle<{
|
||||
hello: (name: string) => Promise<string>;
|
||||
}>("testplugin.greet", "consumer-plugin", {
|
||||
resolveUserId: () => userId,
|
||||
hasPermission: async () => allowed,
|
||||
audit: (entry) => void audits.push(entry as never),
|
||||
});
|
||||
return { handle, audits };
|
||||
}
|
||||
|
||||
it("delegates to the implementation when permitted", async () => {
|
||||
register();
|
||||
const { handle, audits } = handleFor(true);
|
||||
|
||||
await expect(handle.hello("world")).resolves.toBe("hello world");
|
||||
expect(audits).toHaveLength(1);
|
||||
expect(audits[0].success).toBe(true);
|
||||
});
|
||||
|
||||
it("runs the provider as the user the permission was checked for", async () => {
|
||||
const { getActor } = await import("../../plugins/actor.js");
|
||||
register({
|
||||
implementation: { hello: async () => getActor() ?? "nobody" },
|
||||
});
|
||||
const { handle } = handleFor(true, "user-7");
|
||||
|
||||
await expect(handle.hello("x")).resolves.toBe("user-7");
|
||||
});
|
||||
|
||||
it("denies without the permission and never reaches the provider", async () => {
|
||||
const hello = vi.fn(async () => "should not run");
|
||||
register({ implementation: { hello } });
|
||||
const { handle, audits } = handleFor(false);
|
||||
|
||||
await expect(handle.hello("world")).rejects.toThrow(
|
||||
PluginServicePermissionError,
|
||||
);
|
||||
// The gate has to run before delegation, not after.
|
||||
expect(hello).not.toHaveBeenCalled();
|
||||
expect(audits[0].success).toBe(false);
|
||||
});
|
||||
|
||||
it("denies with the same 403 shape requirePermission sends", async () => {
|
||||
register();
|
||||
const { handle } = handleFor(false);
|
||||
|
||||
await handle.hello("world").then(
|
||||
() => expect.unreachable("should have denied"),
|
||||
(error: InstanceType<typeof PluginServicePermissionError>) => {
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.body).toEqual({
|
||||
error: "Insufficient permissions",
|
||||
required: "testplugin.greet.use",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a call it cannot attribute to a user", async () => {
|
||||
register();
|
||||
const { handle } = handleFor(true, undefined);
|
||||
|
||||
await expect(handle.hello("world")).rejects.toThrow(
|
||||
PluginServiceActorError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws once the provider is revoked", async () => {
|
||||
const registration = register();
|
||||
const { handle } = handleFor(true);
|
||||
|
||||
await expect(handle.hello("world")).resolves.toBe("hello world");
|
||||
|
||||
revokeService("testplugin.greet", registration);
|
||||
|
||||
expect(() => handle.hello("world")).toThrow(
|
||||
/no longer provided|is not a function/,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes only the functions the provider handed out", () => {
|
||||
register({
|
||||
implementation: {
|
||||
hello: async () => "hi",
|
||||
secret: "not a function",
|
||||
},
|
||||
});
|
||||
const { handle } = handleFor(true);
|
||||
|
||||
expect(typeof handle.hello).toBe("function");
|
||||
expect(
|
||||
(handle as unknown as Record<string, unknown>).secret,
|
||||
).toBeUndefined();
|
||||
expect(Object.keys(handle)).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("rebinds the acting user with asUser", async () => {
|
||||
register();
|
||||
const seen: string[] = [];
|
||||
const handle = createServiceHandle<{
|
||||
hello: (name: string) => Promise<string>;
|
||||
asUser: (userId: string) => { hello: (n: string) => Promise<string> };
|
||||
}>("testplugin.greet", "consumer-plugin", {
|
||||
resolveUserId: () => undefined,
|
||||
hasPermission: async (userId) => {
|
||||
seen.push(userId);
|
||||
return true;
|
||||
},
|
||||
audit: () => {},
|
||||
});
|
||||
|
||||
await handle.asUser("user-42").hello("world");
|
||||
expect(seen).toEqual(["user-42"]);
|
||||
});
|
||||
|
||||
it("surfaces an unavailable service rather than a generic undefined", () => {
|
||||
const { handle } = handleFor(true);
|
||||
expect(
|
||||
(handle as unknown as Record<string, unknown>).hello,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("audits a failure thrown by the provider itself", async () => {
|
||||
register({
|
||||
implementation: {
|
||||
hello: async () => {
|
||||
throw new Error("provider exploded");
|
||||
},
|
||||
},
|
||||
});
|
||||
const { handle, audits } = handleFor(true);
|
||||
|
||||
await expect(handle.hello("world")).rejects.toThrow("provider exploded");
|
||||
expect(audits[0].success).toBe(false);
|
||||
expect(audits[0].errorMessage).toBe("provider exploded");
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the unavailable error with a stable code", () => {
|
||||
const error = new PluginServiceUnavailableError("a.b", "provider");
|
||||
expect(error.code).toBe("EPLUGINSVCGONE");
|
||||
expect(error.message).toContain("a.b");
|
||||
expect(error.message).toContain("provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider clashes", () => {
|
||||
it("refuses a second plugin answering to the same service and name", async () => {
|
||||
register();
|
||||
expect(() => register({ pluginId: "other-plugin" })).toThrow(
|
||||
/already provided by provider-plugin/,
|
||||
);
|
||||
const { listRegistrationConflicts } =
|
||||
await import("../../plugins/conflicts.js");
|
||||
expect(listRegistrationConflicts()).toContainEqual(
|
||||
expect.objectContaining({ kind: "service", pluginId: "other-plugin" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a plugin replace its own registration", () => {
|
||||
register();
|
||||
expect(() => register()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* contributes.settings validation.
|
||||
*
|
||||
* A settings field is the one thing in a manifest that core writes to the
|
||||
* database on a plugin's behalf, so the schema has to reject anything it would
|
||||
* not know how to store or render.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseManifest,
|
||||
validateManifest,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
} from "../../plugins/manifest.js";
|
||||
|
||||
function base(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "sample-plugin",
|
||||
name: "Sample Plugin",
|
||||
version: "1.0.0",
|
||||
description: "A fixture.",
|
||||
author: { name: "Termix Tests" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
|
||||
capabilities: ["kv:own"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function withSettings(settings: unknown) {
|
||||
return base({ contributes: { settings } });
|
||||
}
|
||||
|
||||
describe("contributes.settings", () => {
|
||||
it("accepts a manifest with every field type", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "apiKey", type: "secret", labelKey: "k" },
|
||||
{ key: "baseUrl", type: "string", labelKey: "k", default: "" },
|
||||
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||
{ key: "enabled", type: "boolean", labelKey: "k" },
|
||||
{
|
||||
key: "mode",
|
||||
type: "select",
|
||||
labelKey: "k",
|
||||
options: [{ value: "a", labelKey: "k" }],
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
type: "multiselect",
|
||||
labelKey: "k",
|
||||
options: [{ value: "a", labelKey: "k" }],
|
||||
},
|
||||
{ key: "notes", type: "textarea", labelKey: "k" },
|
||||
{ key: "raw", type: "json", labelKey: "k" },
|
||||
{ key: "browser", type: "custom", component: "devices" },
|
||||
],
|
||||
user: [{ key: "theme", type: "string", labelKey: "k" }],
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a manifest with no settings at all", () => {
|
||||
expect(validateManifest(base())).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an unknown field property", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", wat: 1 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain('Unknown field "wat"');
|
||||
});
|
||||
|
||||
it("accepts a hidden field and rejects a non-boolean hidden", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({
|
||||
host: {
|
||||
fields: [{ key: "a", type: "number", labelKey: "k", hidden: true }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", hidden: "yes" }],
|
||||
}),
|
||||
);
|
||||
expect(errors.join(" ")).toContain("hidden must be a boolean");
|
||||
});
|
||||
|
||||
it("rejects an unknown scope", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({ global: [{ key: "a", type: "string", labelKey: "k" }] }),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain('Unknown field "global"');
|
||||
});
|
||||
|
||||
it("rejects an unknown field type", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "colour", labelKey: "k" }] }),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must be one of");
|
||||
});
|
||||
|
||||
it("rejects a duplicate key within one scope", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "a", type: "string", labelKey: "k" },
|
||||
{ key: "a", type: "number", labelKey: "k" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain('duplicates "a"');
|
||||
});
|
||||
|
||||
it("allows the same key in two different scopes", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "shared", type: "string", labelKey: "k" }],
|
||||
user: [{ key: "shared", type: "string", labelKey: "k" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires labelKey for everything but a custom field", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "string" }] }),
|
||||
).join(" "),
|
||||
).toContain("labelKey");
|
||||
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "custom", component: "x" }] }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires component on a custom field and forbids it elsewhere", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "custom" }] }),
|
||||
).join(" "),
|
||||
).toContain("component");
|
||||
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", component: "x" }],
|
||||
}),
|
||||
).join(" "),
|
||||
).toContain('only valid when type is "custom"');
|
||||
});
|
||||
|
||||
it("requires non-empty options on select and multiselect", () => {
|
||||
for (const type of ["select", "multiselect"]) {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type, labelKey: "k" }] }),
|
||||
).join(" "),
|
||||
).toContain("options must be a non-empty array");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects options on a field that has no options", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{
|
||||
key: "a",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
options: [{ value: "x", labelKey: "k" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("only valid for select and multiselect");
|
||||
});
|
||||
|
||||
it("rejects min greater than max", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "number", labelKey: "k", min: 10, max: 1 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must not be greater than");
|
||||
});
|
||||
|
||||
it("rejects min or max on a non-number field", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", min: 1 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("only valid for number fields");
|
||||
});
|
||||
|
||||
it("rejects requires that names no boolean in the same scope", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "a", type: "string", labelKey: "k", requires: "missing" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must name a boolean field");
|
||||
});
|
||||
|
||||
it("rejects requires that names a non-boolean field", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "flag", type: "string", labelKey: "k" },
|
||||
{ key: "a", type: "string", labelKey: "k", requires: "flag" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must name a boolean field");
|
||||
});
|
||||
|
||||
it("accepts requires pointing at a boolean declared later", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "a", type: "string", labelKey: "k", requires: "flag" },
|
||||
{ key: "flag", type: "boolean", labelKey: "k" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets a host field require the section's enable key", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [
|
||||
{
|
||||
key: "port",
|
||||
type: "number",
|
||||
labelKey: "k",
|
||||
requires: "enableThing",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a host field colliding with the enable key", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "enableThing", type: "boolean", labelKey: "k" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("already the section's enableKey");
|
||||
});
|
||||
|
||||
it("requires enableLabelKey alongside enableKey", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
host: { enableKey: "enableThing", fields: [] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("enableLabelKey");
|
||||
});
|
||||
|
||||
it("accepts an enableDescriptionKey and rejects an empty one", () => {
|
||||
const host = { enableKey: "enableThing", enableLabelKey: "k", fields: [] };
|
||||
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ host: { ...host, enableDescriptionKey: "d" } }),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ host: { ...host, enableDescriptionKey: "" } }),
|
||||
).join(" "),
|
||||
).toContain("enableDescriptionKey");
|
||||
});
|
||||
|
||||
it("rejects a key that would not be index-safe", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a".repeat(80), type: "string", labelKey: "k" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("short alphanumeric key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings field permissions", () => {
|
||||
it("rejects a short permission the plugin does not declare", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({
|
||||
contributes: {
|
||||
settings: {
|
||||
user: [
|
||||
{ key: "a", type: "string", labelKey: "k", permission: "use" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain(
|
||||
"not declared in contributes.permissions",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a short permission the plugin declares", () => {
|
||||
const { errors, manifest } = parseManifest(
|
||||
base({
|
||||
contributes: {
|
||||
permissions: [{ name: "use", titleKey: "k", descriptionKey: "k" }],
|
||||
settings: {
|
||||
user: [
|
||||
{ key: "a", type: "string", labelKey: "k", permission: "use" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.contributes?.settings?.user?.[0].permission).toBe("use");
|
||||
});
|
||||
|
||||
it("accepts a fully qualified permission from another namespace", () => {
|
||||
// Cross-namespace ids cannot be checked here: the validator has no idea
|
||||
// which other plugins exist. Taken as given, exactly like ctx.rbac does.
|
||||
const { errors } = parseManifest(
|
||||
base({
|
||||
contributes: {
|
||||
settings: {
|
||||
user: [
|
||||
{
|
||||
key: "a",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
permission: "hosts.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host field options", () => {
|
||||
const host = (fields: unknown[], extra: Record<string, unknown> = {}) =>
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "defaultOn", type: "boolean", labelKey: "k" },
|
||||
{ key: "token", type: "secret", labelKey: "k" },
|
||||
],
|
||||
host: { enableKey: "on", enableLabelKey: "k", fields, ...extra },
|
||||
});
|
||||
|
||||
it("accepts enableDefault, defaultFrom, shareRead and ownerOnly", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host(
|
||||
[
|
||||
{
|
||||
key: "mode",
|
||||
type: "boolean",
|
||||
labelKey: "k",
|
||||
defaultFrom: "defaultOn",
|
||||
shareRead: "edit",
|
||||
ownerOnly: true,
|
||||
},
|
||||
],
|
||||
{ enableDefault: true },
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("still accepts an old defaultFrom, but only as a string", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([
|
||||
{
|
||||
key: "mode",
|
||||
type: "boolean",
|
||||
labelKey: "k",
|
||||
defaultFrom: "missing",
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateManifest(
|
||||
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom: 1 }]),
|
||||
).join(),
|
||||
).toMatch(/defaultFrom/);
|
||||
});
|
||||
|
||||
it("accepts defaultable, defaultLevels and personal on host fields", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([
|
||||
{
|
||||
key: "mode",
|
||||
type: "boolean",
|
||||
labelKey: "k",
|
||||
defaultable: false,
|
||||
defaultLevels: ["user", "folder"],
|
||||
personal: true,
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects bad default options and a defaultable secret", () => {
|
||||
const errors = (field: Record<string, unknown>) =>
|
||||
validateManifest(
|
||||
host([{ key: "mode", type: "boolean", labelKey: "k", ...field }]),
|
||||
).join();
|
||||
expect(errors({ defaultable: "yes" })).toMatch(/defaultable/);
|
||||
expect(errors({ personal: 1 })).toMatch(/personal/);
|
||||
expect(errors({ defaultLevels: [] })).toMatch(/defaultLevels/);
|
||||
expect(errors({ defaultLevels: ["host"] })).toMatch(/defaultLevels/);
|
||||
expect(errors({ type: "secret", defaultable: true })).toMatch(
|
||||
/cannot be defaultable/,
|
||||
);
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({
|
||||
user: [{ key: "a", type: "boolean", labelKey: "k", personal: true }],
|
||||
}),
|
||||
).join(),
|
||||
).toMatch(/only valid on host fields/);
|
||||
});
|
||||
|
||||
it("rejects an unknown shareRead level and host options on admin fields", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([
|
||||
{ key: "mode", type: "boolean", labelKey: "k", shareRead: "all" },
|
||||
]),
|
||||
).join(),
|
||||
).toMatch(/shareRead/);
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "a", type: "boolean", labelKey: "k", ownerOnly: true },
|
||||
],
|
||||
}),
|
||||
).join(),
|
||||
).toMatch(/only valid on host fields/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.http.legacyPaths", () => {
|
||||
it("accepts a path under the plugin's own id", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: { http: { legacyPaths: ["/sample-plugin/hook"] } },
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a path outside the plugin's id", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { http: { legacyPaths: ["/users/login"] } } }),
|
||||
).join(),
|
||||
).toMatch(/must start with "\/sample-plugin\/"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* The /plugins/:id/settings routes.
|
||||
*
|
||||
* These decide who may change what, so the tests are mostly about refusal: a
|
||||
* non-admin must not write install-wide configuration, a user must not write
|
||||
* another user's settings or a host they cannot edit, and a secret must never
|
||||
* come back over the wire.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "user-1",
|
||||
permissions: new Set<string>(),
|
||||
hostEditAccess: true,
|
||||
hostOwner: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
runStatement: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
};
|
||||
return { pluginLogger: logger, databaseLogger: logger, apiLogger: logger };
|
||||
});
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
(req as express.Request & { userId?: string }).userId = state.userId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
state.permissions.has(permission),
|
||||
requirePermission:
|
||||
(permission: string) =>
|
||||
(
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
if (!state.permissions.has(permission)) {
|
||||
res.status(403).json({ error: "Insufficient permissions" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
canAccessHost: async () => ({
|
||||
hasAccess: state.hostEditAccess,
|
||||
isOwner: state.hostEditAccess && state.hostOwner,
|
||||
isShared: false,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => {}),
|
||||
getAuditUsername: vi.fn(async (id: string) => id),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const MANIFEST: PluginManifest = {
|
||||
id: "sample",
|
||||
name: "Sample",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "t" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
contributes: {
|
||||
permissions: [{ name: "tweak", titleKey: "k", descriptionKey: "k" }],
|
||||
settings: {
|
||||
admin: [
|
||||
{ key: "apiKey", type: "secret", labelKey: "k" },
|
||||
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||
{
|
||||
key: "narrowed",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
permission: "tweak",
|
||||
},
|
||||
],
|
||||
user: [
|
||||
{ key: "theme", type: "string", labelKey: "k", default: "dark" },
|
||||
{
|
||||
key: "advanced",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
permission: "tweak",
|
||||
},
|
||||
],
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [
|
||||
{ key: "port", type: "number", labelKey: "k" },
|
||||
{ key: "profile", type: "string", labelKey: "k", ownerOnly: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as PluginManifest;
|
||||
|
||||
vi.mock("../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({
|
||||
loader: {
|
||||
get: (id: string) =>
|
||||
id === "sample"
|
||||
? { id, manifest: MANIFEST, state: "active" }
|
||||
: undefined,
|
||||
list: () => [{ id: "sample", manifest: MANIFEST, state: "active" }],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const rows: Row[] = [];
|
||||
|
||||
function find(scope: string, scopeId: string | null, key: string) {
|
||||
return rows.find(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
id === "sample"
|
||||
? { id, manifestJson: JSON.stringify(MANIFEST), state: "enabled" }
|
||||
: null,
|
||||
listAll: async () => [],
|
||||
}),
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async () => [],
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||
rows.filter((row) => row.scope === scope && row.scopeId === scopeId),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.encrypted = encrypted;
|
||||
return;
|
||||
}
|
||||
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||
encryptSystemSecret: async (plaintext: string) =>
|
||||
!plaintext || plaintext.startsWith("sysenc:v1:")
|
||||
? plaintext
|
||||
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
||||
decryptSystemSecret: async (stored: string) =>
|
||||
stored.startsWith("sysenc:v1:")
|
||||
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
|
||||
"utf8",
|
||||
)
|
||||
: stored,
|
||||
}));
|
||||
|
||||
const pluginRoutes = (await import("../../database/routes/plugins.js")).default;
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rows.length = 0;
|
||||
state.userId = "user-1";
|
||||
state.permissions = new Set<string>();
|
||||
state.hostEditAccess = true;
|
||||
state.hostOwner = true;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/plugins", pluginRoutes);
|
||||
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function call(path: string, init?: RequestInit) {
|
||||
return fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
describe("admin scope", () => {
|
||||
it("refuses a reader without admin.plugins.manage", async () => {
|
||||
const response = await call("/plugins/sample/settings/admin");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses a writer without admin.plugins.manage", async () => {
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ retries: 2 }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lets an admin read and write", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const write = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ retries: 3 }),
|
||||
});
|
||||
expect(write.status).toBe(200);
|
||||
|
||||
const read = await call("/plugins/sample/settings/admin");
|
||||
expect((await read.json()).values.retries).toBe(3);
|
||||
});
|
||||
|
||||
it("reports a rejected value per field", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ retries: 99 }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await response.json()).errors.retries).toContain("at most 5");
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses a key the manifest never declared", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ somethingElse: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await response.json()).errors.somethingElse).toBeDefined();
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never returns a secret's value", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: "tskey-very-secret" }),
|
||||
});
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin");
|
||||
const body = await response.text();
|
||||
|
||||
expect(body).not.toContain("tskey-very-secret");
|
||||
expect(JSON.parse(body).values.apiKey).toEqual({ set: true });
|
||||
});
|
||||
|
||||
it("treats a redacted echo as no change", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: "tskey-original" }),
|
||||
});
|
||||
await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: { set: true }, retries: 1 }),
|
||||
});
|
||||
|
||||
const row = rows.find((entry) => entry.key === "apiKey")!;
|
||||
expect(
|
||||
Buffer.from(
|
||||
JSON.parse(row.value!).slice("sysenc:v1:".length),
|
||||
"base64",
|
||||
).toString("utf8"),
|
||||
).toBe("tskey-original");
|
||||
});
|
||||
|
||||
it("keeps the admin gate even for a field naming its own permission", async () => {
|
||||
// Holding the field's permission is not enough: admin scope is admin
|
||||
// scope, so a plugin cannot hand install-wide config to an ordinary user.
|
||||
state.permissions.add("sample.tweak");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ narrowed: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("narrows an admin field to holders of its permission", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const denied = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ narrowed: "x" }),
|
||||
});
|
||||
expect(denied.status).toBe(400);
|
||||
expect((await denied.json()).errors.narrowed).toContain("permission");
|
||||
|
||||
state.permissions.add("sample.tweak");
|
||||
const allowed = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ narrowed: "x" }),
|
||||
});
|
||||
expect(allowed.status).toBe(200);
|
||||
});
|
||||
|
||||
it("404s for a plugin that does not exist", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/nope/settings/admin");
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a body that is not an object", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify([1, 2, 3]),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user scope", () => {
|
||||
it("lets any authenticated user read and write their own", async () => {
|
||||
const write = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ theme: "light" }),
|
||||
});
|
||||
expect(write.status).toBe(200);
|
||||
|
||||
const read = await call("/plugins/sample/settings/user");
|
||||
expect((await read.json()).values.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("scopes writes to the caller, never to a user id in the body", async () => {
|
||||
await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ theme: "light", userId: "someone-else" }),
|
||||
});
|
||||
|
||||
// The bogus key is rejected and the row belongs to the caller.
|
||||
expect(rows.every((row) => row.scopeId === "user-1")).toBe(true);
|
||||
|
||||
state.userId = "user-2";
|
||||
const other = await call("/plugins/sample/settings/user");
|
||||
expect((await other.json()).values.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("refuses a user field gated on a permission the caller lacks", async () => {
|
||||
const response = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ advanced: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await response.json()).errors.advanced).toContain("permission");
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows it once the caller holds that permission", async () => {
|
||||
state.permissions.add("sample.tweak");
|
||||
|
||||
const response = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ advanced: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses an admin field through the user route", async () => {
|
||||
const response = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: "nope" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host scope", () => {
|
||||
it("keeps an owner-only field from a shared editor", async () => {
|
||||
state.hostOwner = false;
|
||||
const write = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ profile: "other", port: 22 }),
|
||||
});
|
||||
expect(write.status).toBe(400);
|
||||
expect((await write.json()).errors.profile).toMatch(/owner/);
|
||||
expect(rows).toHaveLength(0);
|
||||
|
||||
state.hostOwner = true;
|
||||
const owner = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ profile: "mine" }),
|
||||
});
|
||||
expect(owner.status).toBe(200);
|
||||
});
|
||||
|
||||
it("runs the plugin's validators before writing anything", async () => {
|
||||
const { onSettingsValidate, clearSettingsListeners } =
|
||||
await import("../../plugins/settings.js");
|
||||
onSettingsValidate("sample", "host", (values) =>
|
||||
Number(values.port) > 1000 ? { port: "Too high" } : undefined,
|
||||
);
|
||||
const write = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enableThing: true, port: 5000 }),
|
||||
});
|
||||
expect(write.status).toBe(400);
|
||||
expect((await write.json()).errors).toEqual({ port: "Too high" });
|
||||
expect(rows).toHaveLength(0);
|
||||
clearSettingsListeners("sample");
|
||||
});
|
||||
|
||||
it("lets a user with edit access read and write", async () => {
|
||||
const write = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enableThing: true, port: 22 }),
|
||||
});
|
||||
expect(write.status).toBe(200);
|
||||
|
||||
const read = await call("/plugins/sample/settings/host/7");
|
||||
const values = (await read.json()).values;
|
||||
expect(values.enableThing).toBe(true);
|
||||
expect(values.port).toBe(22);
|
||||
});
|
||||
|
||||
it("refuses a host the caller cannot edit", async () => {
|
||||
state.hostEditAccess = false;
|
||||
|
||||
const read = await call("/plugins/sample/settings/host/7");
|
||||
expect(read.status).toBe(403);
|
||||
|
||||
const write = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ port: 22 }),
|
||||
});
|
||||
expect(write.status).toBe(403);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects an invalid host id", async () => {
|
||||
const response = await call("/plugins/sample/settings/host/not-a-number");
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("keeps each host's values apart", async () => {
|
||||
await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ port: 22 }),
|
||||
});
|
||||
await call("/plugins/sample/settings/host/8", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ port: 2222 }),
|
||||
});
|
||||
|
||||
const seven = await (await call("/plugins/sample/settings/host/7")).json();
|
||||
const eight = await (await call("/plugins/sample/settings/host/8")).json();
|
||||
|
||||
expect(seven.values.port).toBe(22);
|
||||
expect(eight.values.port).toBe(2222);
|
||||
});
|
||||
|
||||
it("returns the declared default for an untouched host", async () => {
|
||||
const response = await call("/plugins/sample/settings/host/9");
|
||||
|
||||
expect((await response.json()).values.enableThing).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* The plugin settings service.
|
||||
*
|
||||
* Storage is faked at the repository boundary; everything above it - schema
|
||||
* lookup, validation, encryption, redaction and change notification - is the
|
||||
* real implementation, because that is where the behaviour worth protecting
|
||||
* lives.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const rows: Row[] = [];
|
||||
|
||||
function find(
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) {
|
||||
return rows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
}
|
||||
|
||||
const settingsRepository = {
|
||||
get: vi.fn(
|
||||
async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
),
|
||||
getAll: vi.fn(
|
||||
async (pluginId: string, scope: string, scopeId: string | null) =>
|
||||
rows.filter(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId,
|
||||
),
|
||||
),
|
||||
set: vi.fn(
|
||||
async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.encrypted = encrypted;
|
||||
return;
|
||||
}
|
||||
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
),
|
||||
listByKey: vi.fn(async (pluginId: string, scope: string, key: string) =>
|
||||
rows.filter(
|
||||
(row) =>
|
||||
row.pluginId === pluginId && row.scope === scope && row.key === key,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
const hostOwners = new Map<number, string>();
|
||||
|
||||
const coreSettings = new Map<string, string>();
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => settingsRepository,
|
||||
createCurrentHostRepository: () => ({
|
||||
findById: async (id: number) =>
|
||||
hostOwners.has(id) ? { id, userId: hostOwners.get(id) } : null,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Real AES would need the system key on disk. The prefix and the idempotence
|
||||
// are what the service depends on, so the double keeps both.
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
||||
encryptSystemSecret: async (plaintext: string) =>
|
||||
!plaintext || plaintext.startsWith("sysenc:v1:")
|
||||
? plaintext
|
||||
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
||||
decryptSystemSecret: async (stored: string) =>
|
||||
stored.startsWith("sysenc:v1:")
|
||||
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
|
||||
"utf8",
|
||||
)
|
||||
: stored,
|
||||
}));
|
||||
|
||||
const {
|
||||
CORE_SETTINGS_ALLOWLIST,
|
||||
clearSettingsListeners,
|
||||
declaredFields,
|
||||
findField,
|
||||
getAllSettings,
|
||||
getSetting,
|
||||
listHostValues,
|
||||
onSettingsChange,
|
||||
onSettingsValidate,
|
||||
readCoreSetting,
|
||||
setSetting,
|
||||
validateSettingsSave,
|
||||
} = await import("../../plugins/settings.js");
|
||||
|
||||
function manifest(settings: unknown): PluginManifest {
|
||||
return {
|
||||
id: "sample",
|
||||
name: "Sample",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "t" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
contributes: { settings },
|
||||
} as PluginManifest;
|
||||
}
|
||||
|
||||
const ADMIN = manifest({
|
||||
admin: [
|
||||
{ key: "apiKey", type: "secret", labelKey: "k" },
|
||||
{ key: "baseUrl", type: "string", labelKey: "k", default: "https://x" },
|
||||
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||
{ key: "enabled", type: "boolean", labelKey: "k", default: false },
|
||||
{
|
||||
key: "mode",
|
||||
type: "select",
|
||||
labelKey: "k",
|
||||
options: [
|
||||
{ value: "fast", labelKey: "k" },
|
||||
{ value: "slow", labelKey: "k" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
rows.length = 0;
|
||||
coreSettings.clear();
|
||||
clearSettingsListeners("sample");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("declaredFields", () => {
|
||||
it("returns the fields for each scope", () => {
|
||||
expect(declaredFields(ADMIN, "admin").map((f) => f.key)).toEqual([
|
||||
"apiKey",
|
||||
"baseUrl",
|
||||
"retries",
|
||||
"enabled",
|
||||
"mode",
|
||||
]);
|
||||
expect(declaredFields(ADMIN, "user")).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats the host enable key as a real boolean field", () => {
|
||||
const withHost = manifest({
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
enableDescriptionKey: "d",
|
||||
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
||||
},
|
||||
});
|
||||
|
||||
const fields = declaredFields(withHost, "host");
|
||||
expect(fields.map((f) => f.key)).toEqual(["enableThing", "port"]);
|
||||
expect(fields[0]).toMatchObject({
|
||||
type: "boolean",
|
||||
default: false,
|
||||
descriptionKey: "d",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds a field by key", () => {
|
||||
expect(findField(ADMIN, "admin", "baseUrl")?.type).toBe("string");
|
||||
expect(findField(ADMIN, "admin", "nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSetting", () => {
|
||||
it("writes a valid value and reads it back", async () => {
|
||||
expect(
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://y"),
|
||||
).toBeNull();
|
||||
expect(await getSetting(ADMIN, "admin", null, "baseUrl")).toBe("https://y");
|
||||
});
|
||||
|
||||
it("refuses a key the manifest never declared", async () => {
|
||||
const error = await setSetting(ADMIN, "admin", null, "wat", "x");
|
||||
|
||||
expect(error).toContain("not a settings field this plugin declares");
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses a value that fails the declared constraint", async () => {
|
||||
expect(await setSetting(ADMIN, "admin", null, "retries", 99)).toContain(
|
||||
"at most 5",
|
||||
);
|
||||
expect(
|
||||
await setSetting(ADMIN, "admin", null, "mode", "sideways"),
|
||||
).toContain("must be one of");
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("coerces the strings a form actually sends", async () => {
|
||||
expect(await setSetting(ADMIN, "admin", null, "retries", "3")).toBeNull();
|
||||
expect(await getSetting(ADMIN, "admin", null, "retries")).toBe(3);
|
||||
|
||||
expect(
|
||||
await setSetting(ADMIN, "admin", null, "enabled", "true"),
|
||||
).toBeNull();
|
||||
expect(await getSetting(ADMIN, "admin", null, "enabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps each scope separate", async () => {
|
||||
const scoped = manifest({
|
||||
admin: [{ key: "v", type: "string", labelKey: "k" }],
|
||||
user: [{ key: "v", type: "string", labelKey: "k" }],
|
||||
host: { fields: [{ key: "v", type: "string", labelKey: "k" }] },
|
||||
});
|
||||
|
||||
await setSetting(scoped, "admin", null, "v", "a");
|
||||
await setSetting(scoped, "user", "user-1", "v", "u");
|
||||
await setSetting(scoped, "host", 7, "v", "h");
|
||||
|
||||
expect(await getSetting(scoped, "admin", null, "v")).toBe("a");
|
||||
expect(await getSetting(scoped, "user", "user-1", "v")).toBe("u");
|
||||
expect(await getSetting(scoped, "host", 7, "v")).toBe("h");
|
||||
// A different user sees nothing of the first user's value.
|
||||
expect(await getSetting(scoped, "user", "user-2", "v")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores a host id as text, matching the polymorphic scope column", async () => {
|
||||
const scoped = manifest({
|
||||
host: { fields: [{ key: "v", type: "string", labelKey: "k" }] },
|
||||
});
|
||||
|
||||
await setSetting(scoped, "host", 12, "v", "x");
|
||||
|
||||
expect(rows[0].scopeId).toBe("12");
|
||||
// Either spelling resolves to the same row.
|
||||
expect(await getSetting(scoped, "host", "12", "v")).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults", () => {
|
||||
it("falls back to the declared default when nothing is stored", async () => {
|
||||
expect(await getSetting(ADMIN, "admin", null, "baseUrl")).toBe("https://x");
|
||||
expect(await getSetting(ADMIN, "admin", null, "enabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns undefined for a field with no default and no value", async () => {
|
||||
expect(await getSetting(ADMIN, "admin", null, "retries")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a key the manifest does not declare", async () => {
|
||||
expect(await getSetting(ADMIN, "admin", null, "wat")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("secrets", () => {
|
||||
it("encrypts on the way in and decrypts on the way out", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
||||
|
||||
const row = rows.find((entry) => entry.key === "apiKey")!;
|
||||
expect(row.encrypted).toBe(true);
|
||||
expect(row.value).not.toContain("tskey-secret");
|
||||
expect(JSON.parse(row.value!)).toMatch(/^sysenc:v1:/);
|
||||
|
||||
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe(
|
||||
"tskey-secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts a secret rather than returning it", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
||||
|
||||
const values = await getAllSettings(ADMIN, "admin", null, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
|
||||
expect(values.apiKey).toEqual({ set: true });
|
||||
expect(JSON.stringify(values)).not.toContain("tskey-secret");
|
||||
});
|
||||
|
||||
it("reports an unset secret as set:false", async () => {
|
||||
const values = await getAllSettings(ADMIN, "admin", null, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
|
||||
expect(values.apiKey).toEqual({ set: false });
|
||||
});
|
||||
|
||||
it("treats a redacted echo as leave-it-alone", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "original");
|
||||
|
||||
expect(
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", { set: true }),
|
||||
).toBeNull();
|
||||
|
||||
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe("original");
|
||||
});
|
||||
|
||||
it("lets a secret be replaced and cleared", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "first");
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "second");
|
||||
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe("second");
|
||||
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "");
|
||||
const values = await getAllSettings(ADMIN, "admin", null, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
expect(values.apiKey).toEqual({ set: false });
|
||||
});
|
||||
|
||||
it("hands a plugin its own secret in the clear", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
||||
|
||||
const values = await getAllSettings(ADMIN, "admin", null);
|
||||
|
||||
expect(values.apiKey).toBe("tskey-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllSettings", () => {
|
||||
it("is driven by the manifest, not by the stored rows", async () => {
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://y");
|
||||
// A row for a field the plugin has since dropped.
|
||||
rows.push({
|
||||
pluginId: "sample",
|
||||
scope: "admin",
|
||||
scopeId: null,
|
||||
key: "removedField",
|
||||
value: JSON.stringify("stale"),
|
||||
encrypted: false,
|
||||
});
|
||||
|
||||
const values = await getAllSettings(ADMIN, "admin", null);
|
||||
|
||||
expect(values.baseUrl).toBe("https://y");
|
||||
expect(values.enabled).toBe(false);
|
||||
expect(values).not.toHaveProperty("removedField");
|
||||
});
|
||||
|
||||
it("returns nothing for a scope the plugin declares no fields in", async () => {
|
||||
expect(await getAllSettings(ADMIN, "user", "user-1")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onSettingsChange", () => {
|
||||
it("fires on a successful write", async () => {
|
||||
const listener = vi.fn();
|
||||
onSettingsChange("sample", "baseUrl", listener);
|
||||
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||
|
||||
expect(listener).toHaveBeenCalledWith("https://z");
|
||||
});
|
||||
|
||||
it("does not fire for a rejected write", async () => {
|
||||
const listener = vi.fn();
|
||||
onSettingsChange("sample", "retries", listener);
|
||||
|
||||
await setSetting(ADMIN, "admin", null, "retries", 99);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops firing once unsubscribed", async () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = onSettingsChange("sample", "baseUrl", listener);
|
||||
|
||||
unsubscribe();
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only notifies listeners for that key and plugin", async () => {
|
||||
const other = vi.fn();
|
||||
const another = vi.fn();
|
||||
onSettingsChange("sample", "enabled", other);
|
||||
onSettingsChange("different-plugin", "baseUrl", another);
|
||||
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
expect(another).not.toHaveBeenCalled();
|
||||
clearSettingsListeners("different-plugin");
|
||||
});
|
||||
|
||||
it("survives a listener that throws", async () => {
|
||||
const good = vi.fn();
|
||||
onSettingsChange("sample", "baseUrl", () => {
|
||||
throw new Error("listener blew up");
|
||||
});
|
||||
onSettingsChange("sample", "baseUrl", good);
|
||||
|
||||
await expect(
|
||||
setSetting(ADMIN, "admin", null, "baseUrl", "https://z"),
|
||||
).resolves.toBeNull();
|
||||
expect(good).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops every listener when the plugin deactivates", async () => {
|
||||
const listener = vi.fn();
|
||||
onSettingsChange("sample", "baseUrl", listener);
|
||||
|
||||
clearSettingsListeners("sample");
|
||||
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCoreSetting", () => {
|
||||
it("returns an allowlisted key", async () => {
|
||||
coreSettings.set("app_name", "Termix");
|
||||
|
||||
expect(await readCoreSetting("app_name")).toBe("Termix");
|
||||
});
|
||||
|
||||
it("refuses a key that is not on the allowlist", async () => {
|
||||
coreSettings.set("jwt_secret", "nope");
|
||||
|
||||
await expect(readCoreSetting("jwt_secret")).rejects.toThrow(
|
||||
/not readable by plugins/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose anything sensitive through the allowlist", () => {
|
||||
for (const key of CORE_SETTINGS_ALLOWLIST) {
|
||||
expect(key).not.toMatch(/secret|password|token|key$/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings validators", () => {
|
||||
it("collects every validator's errors for its scope only", async () => {
|
||||
const stop = onSettingsValidate("validated", "admin", (values) =>
|
||||
values.url === "bad" ? { url: "Nope" } : undefined,
|
||||
);
|
||||
onSettingsValidate("validated", "user", () => ({ other: "Wrong scope" }));
|
||||
|
||||
expect(
|
||||
await validateSettingsSave("validated", "admin", null, { url: "bad" }),
|
||||
).toEqual({ url: "Nope" });
|
||||
expect(
|
||||
await validateSettingsSave("validated", "admin", null, { url: "ok" }),
|
||||
).toEqual({});
|
||||
|
||||
stop();
|
||||
clearSettingsListeners("validated");
|
||||
expect(await validateSettingsSave("validated", "user", null, {})).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the host id to a host validator", async () => {
|
||||
let seen: number | undefined;
|
||||
onSettingsValidate("validated-host", "host", (_values, context) => {
|
||||
seen = context.hostId;
|
||||
});
|
||||
await validateSettingsSave("validated-host", "host", "12", {});
|
||||
expect(seen).toBe(12);
|
||||
clearSettingsListeners("validated-host");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listHostValues", () => {
|
||||
const withHostField = manifest({
|
||||
host: {
|
||||
enableKey: "on",
|
||||
enableLabelKey: "x",
|
||||
fields: [{ key: "token", type: "secret", labelKey: "x" }],
|
||||
},
|
||||
});
|
||||
|
||||
it("lists stored values with each host's owner", async () => {
|
||||
hostOwners.set(7, "alice");
|
||||
hostOwners.set(8, "bob");
|
||||
await setSetting(withHostField, "host", 7, "on", true);
|
||||
await setSetting(withHostField, "host", 8, "on", false);
|
||||
await setSetting(withHostField, "host", 99, "on", true);
|
||||
|
||||
const listed = await listHostValues(withHostField, "on");
|
||||
expect(listed).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ hostId: 7, userId: "alice", value: true },
|
||||
{ hostId: 8, userId: "bob", value: false },
|
||||
]),
|
||||
);
|
||||
// A row for a host that no longer exists is skipped.
|
||||
expect(listed.find((entry) => entry.hostId === 99)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a secret field", async () => {
|
||||
await expect(listHostValues(withHostField, "token")).rejects.toThrow(
|
||||
/secret/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enableDefault", () => {
|
||||
it("is what the host switch reads before a host saves it", () => {
|
||||
const fields = declaredFields(
|
||||
manifest({
|
||||
host: {
|
||||
enableKey: "on",
|
||||
enableLabelKey: "x",
|
||||
enableDefault: true,
|
||||
fields: [],
|
||||
},
|
||||
}),
|
||||
"host",
|
||||
);
|
||||
expect(fields[0]).toMatchObject({ key: "on", default: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Core owns shutdown.
|
||||
*
|
||||
* gracefulShutdown in starter.ts calls shutdownPlugins(), which runs every
|
||||
* plugin's deactivate and disposes what it registered. A plugin does not need
|
||||
* its own process signal handler, and must not have one: the docker console
|
||||
* used to install a SIGTERM listener that called process.exit(0), which
|
||||
* skipped the rest of the sequence including other plugins and the database
|
||||
* flush.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PluginLoader } from "../../plugins/loader.js";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
assertCapability: vi.fn().mockResolvedValue(undefined),
|
||||
hasCapability: vi.fn().mockResolvedValue(true),
|
||||
PluginCapabilityError: class extends Error {},
|
||||
invalidatePluginPermissionCache: vi.fn(),
|
||||
}));
|
||||
|
||||
const CONSOLE_PORT = 39109;
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
function isListening(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect({ port, host: "127.0.0.1" });
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.DATA_DIR;
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
/**
|
||||
* Shaped like the docker plugin: a REST server and a console WebSocket
|
||||
* server, both started inside activate and closed through disposables.
|
||||
*/
|
||||
const DOCKER_LIKE = `
|
||||
import net from "node:net";
|
||||
|
||||
export async function activate(ctx) {
|
||||
const console = net.createServer();
|
||||
await new Promise((resolve) =>
|
||||
console.listen(${CONSOLE_PORT}, "127.0.0.1", resolve),
|
||||
);
|
||||
ctx.disposables.add(
|
||||
() => new Promise((resolve) => console.close(() => resolve())),
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
describe("shutdownPlugins closes what plugins own", () => {
|
||||
it("closes a console-style WebSocket server on shutdown", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({
|
||||
id: "docker",
|
||||
root: bundled,
|
||||
backendSource: DOCKER_LIKE,
|
||||
});
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
||||
process.env.DATA_DIR = user;
|
||||
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
await loader.activate("docker");
|
||||
|
||||
expect(await isListening(CONSOLE_PORT)).toBe(true);
|
||||
|
||||
await loader.shutdown();
|
||||
|
||||
expect(await isListening(CONSOLE_PORT)).toBe(false);
|
||||
});
|
||||
|
||||
// One plugin failing to shut down cleanly must not strand the others,
|
||||
// which is the other half of why core drives this rather than each plugin.
|
||||
it("keeps going when one plugin's deactivate throws", async () => {
|
||||
const marker = path.join(tempRoot("termix-marker-"), "stopped.txt");
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
|
||||
createFixturePlugin({
|
||||
id: "alpha",
|
||||
root: bundled,
|
||||
backendSource: `
|
||||
export async function activate() {}
|
||||
export async function deactivate() {
|
||||
throw new Error("alpha refuses to stop");
|
||||
}
|
||||
`,
|
||||
});
|
||||
createFixturePlugin({
|
||||
id: "beta",
|
||||
root: bundled,
|
||||
backendSource: `
|
||||
import fs from "node:fs";
|
||||
export async function activate() {}
|
||||
export async function deactivate() {
|
||||
fs.appendFileSync(${JSON.stringify(marker)}, "beta stopped\\n");
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
||||
process.env.DATA_DIR = user;
|
||||
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
await loader.activateAll(["alpha", "beta"]);
|
||||
|
||||
await loader.shutdown();
|
||||
|
||||
expect(loader.get("alpha")?.state).toBe("stopped");
|
||||
expect(loader.get("beta")?.state).toBe("stopped");
|
||||
expect(fs.readFileSync(marker, "utf8")).toBe("beta stopped\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* TERMIX_REQUIRE_SIGNED_PLUGINS against the real loader and real archives.
|
||||
* The pinned key list is swapped for a test key; nothing else is mocked.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
import { PluginLoader } from "../../plugins/loader.js";
|
||||
|
||||
const testKey = vi.hoisted(() => ({
|
||||
privateKey: null as crypto.KeyObject | null,
|
||||
key: { id: "test", publicKey: "", addedIn: "2.9.0" },
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/trust.js", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../../plugins/trust.js")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
||||
actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginStorageRepository: () => ({
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
delete: async () => false,
|
||||
listKeys: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
{
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
testKey.privateKey = privateKey;
|
||||
testKey.key.publicKey = publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Bundled root with one plugin, and an empty user plugins dir. */
|
||||
function setup() {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const data = tempRoot("termix-data-");
|
||||
const userDir = path.join(data, "plugins");
|
||||
fs.mkdirSync(userDir, { recursive: true });
|
||||
createFixturePlugin({ id: "bundled-one", root: bundled });
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
||||
process.env.DATA_DIR = data;
|
||||
return { userDir };
|
||||
}
|
||||
|
||||
/** Writes <id>-1.0.0.tmxplug into `dir`, optionally with a .sig. */
|
||||
async function writeArtifact(
|
||||
dir: string,
|
||||
id: string,
|
||||
sig: "valid" | "wrong-key" | "none",
|
||||
version = "1.0.0",
|
||||
): Promise<string> {
|
||||
const build = tempRoot("termix-build-");
|
||||
createFixturePlugin({ id, root: build, manifestOverrides: { version } });
|
||||
const file = path.join(dir, `${id}-${version}.tmxplug`);
|
||||
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, [
|
||||
"manifest.json",
|
||||
"backend",
|
||||
]);
|
||||
if (sig !== "none") {
|
||||
const signer =
|
||||
sig === "valid"
|
||||
? testKey.privateKey
|
||||
: crypto.generateKeyPairSync("ed25519").privateKey;
|
||||
const digest = crypto
|
||||
.createHash("sha256")
|
||||
.update(fs.readFileSync(file))
|
||||
.digest();
|
||||
fs.writeFileSync(
|
||||
`${file}.sig`,
|
||||
crypto.sign(null, digest, signer).toString("base64"),
|
||||
);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
async function loadedIds(): Promise<string[]> {
|
||||
const loader = new PluginLoader();
|
||||
const loaded = await loader.loadAll();
|
||||
return loaded.map((plugin) => plugin.id).sort();
|
||||
}
|
||||
|
||||
describe("signed plugins gate off", () => {
|
||||
it("loads a user folder, an unsigned archive and a signed archive", async () => {
|
||||
const { userDir } = setup();
|
||||
createFixturePlugin({ id: "folder-one", root: userDir });
|
||||
await writeArtifact(userDir, "unsigned-one", "none");
|
||||
await writeArtifact(userDir, "signed-one", "valid");
|
||||
|
||||
expect(await loadedIds()).toEqual([
|
||||
"bundled-one",
|
||||
"folder-one",
|
||||
"signed-one",
|
||||
"unsigned-one",
|
||||
]);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
path.join(userDir, ".unpacked", "signed-one", "manifest.json"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still blocks an archive whose .sig does not verify", async () => {
|
||||
const { userDir } = setup();
|
||||
await writeArtifact(userDir, "forged-one", "wrong-key");
|
||||
expect(await loadedIds()).toEqual(["bundled-one"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signed plugins gate on", () => {
|
||||
it("blocks user folders and unsigned archives, keeps bundled plugins", async () => {
|
||||
const { userDir } = setup();
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
createFixturePlugin({ id: "folder-one", root: userDir });
|
||||
await writeArtifact(userDir, "unsigned-one", "none");
|
||||
await writeArtifact(userDir, "forged-one", "wrong-key");
|
||||
await writeArtifact(userDir, "signed-one", "valid");
|
||||
|
||||
expect(await loadedIds()).toEqual(["bundled-one", "signed-one"]);
|
||||
});
|
||||
|
||||
it("refuses a signed archive of a bundled id that is not newer", async () => {
|
||||
const { userDir } = setup();
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
await writeArtifact(userDir, "bundled-one", "valid");
|
||||
const loader = new PluginLoader();
|
||||
const loaded = await loader.loadAll();
|
||||
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
|
||||
expect(loader.get("bundled-one")?.artifact).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updating a bundled plugin", () => {
|
||||
it("loads a signed, newer archive in place of the bundled copy", async () => {
|
||||
const { userDir } = setup();
|
||||
const file = await writeArtifact(userDir, "bundled-one", "valid", "1.0.1");
|
||||
const loader = new PluginLoader();
|
||||
const loaded = await loader.loadAll();
|
||||
|
||||
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
|
||||
const plugin = loader.get("bundled-one");
|
||||
expect(plugin?.manifest.version).toBe("1.0.1");
|
||||
expect(plugin?.source).toBe("bundled");
|
||||
expect(plugin?.artifact).toBe(file);
|
||||
expect(plugin?.signedBy).toBe("test");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for an unsigned newer archive", async () => {
|
||||
const { userDir } = setup();
|
||||
await writeArtifact(userDir, "bundled-one", "none", "2.0.0");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for a signed older archive", async () => {
|
||||
const { userDir } = setup();
|
||||
await writeArtifact(userDir, "bundled-one", "valid", "0.9.0");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("refuses a downgrade after the bundled copy was unloaded", async () => {
|
||||
const { userDir } = setup();
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
loader.forget("bundled-one");
|
||||
const file = await writeArtifact(userDir, "bundled-one", "valid", "0.5.0");
|
||||
|
||||
await expect(
|
||||
loader.loadArtifact(file, loader.bundledIds()),
|
||||
).rejects.toThrow(/not newer than the bundled 1\.0\.0/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The CLI's ?url import: the file lands under dist/assets/ and the import
|
||||
* becomes a URL relative to the bundle, which /plugin-assets/<id>/ serves.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as esbuild from "esbuild";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
// @ts-expect-error the CLI is plain ESM without types
|
||||
import { staticUrlImports } from "../../../../packages/plugin-sdk/cli/lib/static-url-imports.mjs";
|
||||
|
||||
const dirs: string[] = [];
|
||||
function tempDir() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-url-"));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function bundle(entrySource: string, files: Record<string, string>) {
|
||||
const dir = tempDir();
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(dir, name), contents);
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, "entry.js"), entrySource);
|
||||
const outDir = path.join(dir, "dist");
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [path.join(dir, "entry.js")],
|
||||
outfile: path.join(outDir, "frontend.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
write: true,
|
||||
logLevel: "silent",
|
||||
plugins: [staticUrlImports({ outDir })],
|
||||
});
|
||||
return { outDir, result };
|
||||
}
|
||||
|
||||
describe("staticUrlImports", () => {
|
||||
it("copies a worker next to the bundle and imports its URL", async () => {
|
||||
const { outDir } = await bundle(
|
||||
`import url from "./worker.mjs?url"; export default url;`,
|
||||
{ "worker.mjs": "self.onmessage = () => {};" },
|
||||
);
|
||||
|
||||
const assets = fs.readdirSync(path.join(outDir, "assets"));
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toMatch(/^worker-[0-9a-f]{8}\.js$/);
|
||||
const code = fs.readFileSync(path.join(outDir, "frontend.js"), "utf8");
|
||||
expect(code).toContain(`new URL("./assets/${assets[0]}", import.meta.url)`);
|
||||
});
|
||||
|
||||
it("refuses a file the asset route would not serve", async () => {
|
||||
await expect(
|
||||
bundle(`import url from "./image.png?url"; export default url;`, {
|
||||
"image.png": "not really",
|
||||
}),
|
||||
).rejects.toThrow(/only script files/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_OWNER,
|
||||
getEntity,
|
||||
hasEntity,
|
||||
listEntities,
|
||||
listEntityTypes,
|
||||
registerEntity,
|
||||
resetSyncRegistry,
|
||||
unregisterByOwner,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import {
|
||||
mapKeybindingReferences,
|
||||
registerCoreSyncEntities,
|
||||
} from "../../sync/entities.js";
|
||||
import { setKeybindingActionSource } from "../../database/routes/keybinding-validation.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetSyncRegistry();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetSyncRegistry();
|
||||
});
|
||||
|
||||
describe("sync registry", () => {
|
||||
it("orders entities by their declared order, then by name", () => {
|
||||
registerEntity("core", { type: "b", table: {}, order: 20 });
|
||||
registerEntity("core", { type: "a", table: {}, order: 10 });
|
||||
registerEntity("core", { type: "c", table: {}, order: 10 });
|
||||
|
||||
expect(listEntityTypes()).toEqual(["a", "c", "b"]);
|
||||
});
|
||||
|
||||
it("defaults the user column and the optional lists", () => {
|
||||
registerEntity("core", { type: "thing", table: {} });
|
||||
|
||||
expect(getEntity("thing")).toMatchObject({
|
||||
userColumn: "userId",
|
||||
readOnlyFields: [],
|
||||
encryptedFields: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an entity another owner already registered", () => {
|
||||
registerEntity("core", { type: "hosts", table: {} });
|
||||
|
||||
expect(() =>
|
||||
registerEntity("evil-plugin", { type: "hosts", table: {} }),
|
||||
).toThrow(/already registered by "core"/);
|
||||
});
|
||||
|
||||
it("lets the same owner re-register, which is what reactivation does", () => {
|
||||
registerEntity("demo", { type: "thing", table: {}, order: 1 });
|
||||
registerEntity("demo", { type: "thing", table: {}, order: 2 });
|
||||
|
||||
expect(getEntity("thing")?.order).toBe(2);
|
||||
});
|
||||
|
||||
it("removes an entity when its registration is disposed", () => {
|
||||
const dispose = registerEntity("demo", { type: "thing", table: {} });
|
||||
|
||||
expect(hasEntity("thing")).toBe(true);
|
||||
dispose();
|
||||
expect(hasEntity("thing")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a stale disposer remove a newer registration", () => {
|
||||
// A plugin that crashed and restarted must not revoke the registration
|
||||
// its own restart installed.
|
||||
const stale = registerEntity("demo", { type: "thing", table: {} });
|
||||
resetSyncRegistry();
|
||||
registerEntity("other", { type: "thing", table: {} });
|
||||
|
||||
stale();
|
||||
|
||||
expect(getEntity("thing")?.owner).toBe("other");
|
||||
});
|
||||
|
||||
it("removes everything an owner registered", () => {
|
||||
registerEntity("demo", { type: "a", table: {} });
|
||||
registerEntity("demo", { type: "b", table: {} });
|
||||
registerEntity("core", { type: "c", table: {} });
|
||||
|
||||
unregisterByOwner("demo");
|
||||
|
||||
expect(listEntityTypes()).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("needs a type", () => {
|
||||
expect(() => registerEntity("demo", { type: "", table: {} })).toThrow(
|
||||
/needs a type/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entity aliases", () => {
|
||||
it("finds an entity by a name core uses for it", () => {
|
||||
registerEntity("demo", {
|
||||
type: "snippets",
|
||||
table: {},
|
||||
answersTo: ["commandSnippet"],
|
||||
});
|
||||
|
||||
expect(getEntity("commandSnippet")?.type).toBe("snippets");
|
||||
expect(getEntity("snippets")?.type).toBe("snippets");
|
||||
expect(getEntity("nothing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps core-only options away from plugins", () => {
|
||||
registerEntity(
|
||||
"demo",
|
||||
{ type: "thing", table: {} },
|
||||
{ readOnly: true, load: async () => [] },
|
||||
);
|
||||
|
||||
expect(getEntity("thing")?.readOnly).toBeUndefined();
|
||||
expect(getEntity("thing")?.load).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("core sync entities", () => {
|
||||
beforeEach(() => {
|
||||
registerCoreSyncEntities();
|
||||
});
|
||||
|
||||
it("registers what core syncs, under the wire names 2.8 used", () => {
|
||||
expect(listEntityTypes()).toEqual([
|
||||
"accountProfile",
|
||||
"hostDefaultsAdmin",
|
||||
"sshCredentials",
|
||||
"sharedCredentials",
|
||||
"sshFolders",
|
||||
"hosts",
|
||||
"sharedHosts",
|
||||
"hostDefaults",
|
||||
"userPreferences",
|
||||
"pluginUserSettings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("owns all of them as core", () => {
|
||||
for (const entity of listEntities()) {
|
||||
expect(entity.owner).toBe(CORE_OWNER);
|
||||
}
|
||||
});
|
||||
|
||||
it("sorts every column reference target before the entity that points at it", () => {
|
||||
const order = new Map(
|
||||
listEntities().map((entity) => [entity.type, entity.order]),
|
||||
);
|
||||
|
||||
for (const entity of listEntities()) {
|
||||
for (const reference of entity.references ?? []) {
|
||||
// Rows pointing at themselves are ordered within the entity, and a
|
||||
// JSON reference that arrives first is written again once the
|
||||
// target exists.
|
||||
if (reference.entityType === entity.type) continue;
|
||||
if (reference.field.includes(".")) continue;
|
||||
expect(
|
||||
order.get(reference.entityType),
|
||||
`${entity.type} -> ${reference.entityType}`,
|
||||
).toBeLessThan(entity.order);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("marks the singletons and nothing else", () => {
|
||||
const singletons = listEntities()
|
||||
.filter((entity) => entity.singleton)
|
||||
.map((entity) => entity.type);
|
||||
|
||||
expect(singletons.sort()).toEqual([
|
||||
"accountProfile",
|
||||
"hostDefaultsAdmin",
|
||||
"userPreferences",
|
||||
]);
|
||||
});
|
||||
|
||||
it("only sends shared copies and the account one way", () => {
|
||||
const readOnly = listEntities()
|
||||
.filter((entity) => entity.readOnly)
|
||||
.map((entity) => entity.type);
|
||||
|
||||
expect(readOnly.sort()).toEqual([
|
||||
"accountProfile",
|
||||
"hostDefaultsAdmin",
|
||||
"sharedCredentials",
|
||||
"sharedHosts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps device-only host fields off the wire", () => {
|
||||
expect(getEntity("hosts")?.readOnlyFields).toEqual(
|
||||
expect.arrayContaining(["connectionOrigin", "localOnly", "sharedSource"]),
|
||||
);
|
||||
expect(getEntity("userPreferences")?.readOnlyFields).toEqual([
|
||||
"storageMode",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves local-only and shared rows out of what a device sends", () => {
|
||||
const hosts = getEntity("hosts")!;
|
||||
expect(hosts.shouldSync?.({ localOnly: true })).toBe(false);
|
||||
expect(hosts.shouldSync?.({ sharedSource: "{}" })).toBe(false);
|
||||
expect(hosts.shouldSync?.({ localOnly: false, sharedSource: null })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves plugin data out of the host row, which travels in pluginSettings", () => {
|
||||
const hosts = getEntity("hosts");
|
||||
expect(
|
||||
(hosts?.references ?? []).map((reference) => reference.entityType),
|
||||
).not.toContain("commandSnippet");
|
||||
expect(hosts?.readOnlyFields).toContain("quickActions");
|
||||
});
|
||||
|
||||
it("translates a keybinding parameter that names a sync entity", async () => {
|
||||
setKeybindingActionSource(() => [
|
||||
{
|
||||
pluginId: "fixture",
|
||||
id: "runThing",
|
||||
params: {
|
||||
thingId: { type: "string", syncEntity: "things" },
|
||||
label: { type: "string" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
try {
|
||||
const row = {
|
||||
customKeybindings: JSON.stringify([
|
||||
{ id: "a", action: { type: "runThing", thingId: "4", label: "4" } },
|
||||
{ id: "b", action: { type: "nextTab" } },
|
||||
]),
|
||||
};
|
||||
const out = await mapKeybindingReferences(row, async (entity, value) =>
|
||||
entity === "things" && value === "4" ? "sync-4" : null,
|
||||
);
|
||||
const bindings = JSON.parse(out.customKeybindings as string);
|
||||
expect(bindings[0].action).toEqual({
|
||||
type: "runThing",
|
||||
thingId: "sync-4",
|
||||
label: "4",
|
||||
});
|
||||
expect(bindings[1].action).toEqual({ type: "nextTab" });
|
||||
expect(
|
||||
await mapKeybindingReferences(
|
||||
{ customKeybindings: null },
|
||||
async () => "x",
|
||||
),
|
||||
).toEqual({ customKeybindings: null });
|
||||
} finally {
|
||||
setKeybindingActionSource(() => []);
|
||||
}
|
||||
});
|
||||
|
||||
it("encrypts host and credential secrets", () => {
|
||||
expect(getEntity("hosts")?.encryptedFields).toContain("password");
|
||||
expect(getEntity("sshCredentials")?.encryptedFields).toContain(
|
||||
"privateKey",
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent, because several modules prime it", () => {
|
||||
const before = listEntityTypes();
|
||||
registerCoreSyncEntities();
|
||||
|
||||
expect(listEntityTypes()).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defineTable,
|
||||
id,
|
||||
varchar,
|
||||
text,
|
||||
json,
|
||||
boolean,
|
||||
timestamp,
|
||||
integer,
|
||||
bigint,
|
||||
encryptedText,
|
||||
refUser,
|
||||
refHost,
|
||||
prefixedTableName,
|
||||
tablePrefix,
|
||||
adoptLegacyTable,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
import { splitStatements } from "@termix/plugin-sdk/ddl";
|
||||
import {
|
||||
adoptTableSql,
|
||||
createTableSql,
|
||||
dropTableSql,
|
||||
addColumnSql,
|
||||
keyedColumns,
|
||||
buildTable,
|
||||
} from "../../plugins/table-builder.js";
|
||||
|
||||
/**
|
||||
* The three-dialect DDL is what verify:dialect would catch against a real
|
||||
* server, and that needs a live Postgres and MySQL. These snapshots are what
|
||||
* covers it in a run that has neither.
|
||||
*/
|
||||
const definition = defineTable(
|
||||
"note",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
title: varchar(200).notNull(),
|
||||
body: text(),
|
||||
meta: json(),
|
||||
secret: encryptedText(),
|
||||
pinned: boolean().default(false),
|
||||
position: integer().default(0),
|
||||
size: bigint(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
uniques: [{ name: "idx_note_user_title", columns: ["userId", "title"] }],
|
||||
indexes: [{ name: "idx_note_host", columns: ["hostId"] }],
|
||||
},
|
||||
);
|
||||
|
||||
describe("table naming", () => {
|
||||
it("prefixes with the plugin id, dashes becoming underscores", () => {
|
||||
expect(tablePrefix("network-topology")).toBe("p_network_topology_");
|
||||
expect(prefixedTableName("network-topology", "graph")).toBe(
|
||||
"p_network_topology_graph",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defineTable validation", () => {
|
||||
it("refuses an index on an unbounded text column", () => {
|
||||
expect(() =>
|
||||
defineTable(
|
||||
"bad",
|
||||
{ id: id(), body: text() },
|
||||
{ indexes: [{ name: "idx_bad", columns: ["body"] }] },
|
||||
),
|
||||
).toThrow(/MySQL cannot index an unbounded column/);
|
||||
});
|
||||
|
||||
it("refuses an index on a json column", () => {
|
||||
expect(() =>
|
||||
defineTable(
|
||||
"bad",
|
||||
{ id: id(), meta: json() },
|
||||
{ indexes: [{ name: "idx_bad", columns: ["meta"] }] },
|
||||
),
|
||||
).toThrow(/MySQL cannot index an unbounded column/);
|
||||
});
|
||||
|
||||
it("refuses an index naming a column the table does not declare", () => {
|
||||
expect(() =>
|
||||
defineTable(
|
||||
"bad",
|
||||
{ id: id() },
|
||||
{ indexes: [{ name: "idx_bad", columns: ["nope"] }] },
|
||||
),
|
||||
).toThrow(/which table "bad" does not declare/);
|
||||
});
|
||||
|
||||
it("refuses a table name that is not lower snake_case", () => {
|
||||
expect(() => defineTable("Notes", { id: id() })).toThrow(
|
||||
/lower snake_case/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a table with no columns", () => {
|
||||
expect(() => defineTable("empty", {})).toThrow(/declares no columns/);
|
||||
});
|
||||
|
||||
it("treats uniques as indexes that happen to be unique", () => {
|
||||
const table = defineTable(
|
||||
"thing",
|
||||
{ id: id(), name: varchar(50) },
|
||||
{ uniques: [{ name: "idx_thing_name", columns: ["name"] }] },
|
||||
);
|
||||
|
||||
expect(table.indexes[0]).toMatchObject({
|
||||
name: "idx_thing_name",
|
||||
unique: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyedColumns", () => {
|
||||
it("counts primary keys, uniques, refs and indexed columns", () => {
|
||||
const keyed = keyedColumns(definition);
|
||||
|
||||
expect(keyed.has("userId")).toBe(true);
|
||||
expect(keyed.has("hostId")).toBe(true);
|
||||
expect(keyed.has("title")).toBe(true);
|
||||
// Not keyed, so it stays TEXT and is not narrowed to varchar(255).
|
||||
expect(keyed.has("body")).toBe(false);
|
||||
expect(keyed.has("meta")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTableSql", () => {
|
||||
it("emits sqlite DDL", () => {
|
||||
expect(createTableSql("sqlite", "demo", definition).join("\n"))
|
||||
.toMatchInlineSnapshot(`
|
||||
"CREATE TABLE IF NOT EXISTS "p_demo_note" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"body" text,
|
||||
"meta" text,
|
||||
"secret" text,
|
||||
"pinned" integer DEFAULT 0,
|
||||
"position" integer DEFAULT 0,
|
||||
"size" integer,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_note_user_title" ON "p_demo_note" ("user_id", "title");
|
||||
CREATE INDEX IF NOT EXISTS "idx_note_host" ON "p_demo_note" ("host_id");"
|
||||
`);
|
||||
});
|
||||
|
||||
it("emits postgres DDL with serial and native booleans", () => {
|
||||
expect(createTableSql("postgres", "demo", definition).join("\n"))
|
||||
.toMatchInlineSnapshot(`
|
||||
"CREATE TABLE IF NOT EXISTS "p_demo_note" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"title" varchar(200) NOT NULL,
|
||||
"body" text,
|
||||
"meta" text,
|
||||
"secret" text,
|
||||
"pinned" boolean DEFAULT false,
|
||||
"position" integer DEFAULT 0,
|
||||
"size" bigint,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_note_user_title" ON "p_demo_note" ("user_id", "title");
|
||||
CREATE INDEX IF NOT EXISTS "idx_note_host" ON "p_demo_note" ("host_id");"
|
||||
`);
|
||||
});
|
||||
|
||||
it("emits mysql DDL with backticks and a parenthesised timestamp default", () => {
|
||||
expect(createTableSql("mysql", "demo", definition).join("\n"))
|
||||
.toMatchInlineSnapshot(`
|
||||
"CREATE TABLE IF NOT EXISTS \`p_demo_note\` (
|
||||
\`id\` int AUTO_INCREMENT PRIMARY KEY,
|
||||
\`user_id\` varchar(255) NOT NULL,
|
||||
\`host_id\` int NOT NULL,
|
||||
\`title\` varchar(200) NOT NULL,
|
||||
\`body\` text,
|
||||
\`meta\` text,
|
||||
\`secret\` text,
|
||||
\`pinned\` boolean DEFAULT false,
|
||||
\`position\` int DEFAULT 0,
|
||||
\`size\` bigint,
|
||||
\`created_at\` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (\`host_id\`) REFERENCES \`ssh_data\` (\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX \`idx_note_user_title\` ON \`p_demo_note\` (\`user_id\`, \`title\`);
|
||||
CREATE INDEX \`idx_note_host\` ON \`p_demo_note\` (\`host_id\`);"
|
||||
`);
|
||||
});
|
||||
|
||||
it("never leaves an indexed column as TEXT off sqlite", () => {
|
||||
// MySQL 8 rejects a TEXT column in a key specification. MariaDB accepts
|
||||
// it, which is why this was missed in core until a real MySQL saw it.
|
||||
for (const dialect of ["postgres", "mysql"] as const) {
|
||||
const sql = createTableSql(dialect, "demo", definition).join("\n");
|
||||
const keyed = keyedColumns(definition);
|
||||
for (const property of keyed) {
|
||||
const column = property.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
||||
const line = sql
|
||||
.split("\n")
|
||||
.find(
|
||||
(row) => row.includes(`${column}\``) || row.includes(`"${column}"`),
|
||||
);
|
||||
expect(line, `${dialect}.${column}`).not.toMatch(/\s(text|json)[,\s]/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("omits IF NOT EXISTS on a mysql index, which does not support it", () => {
|
||||
const mysql = createTableSql("mysql", "demo", definition);
|
||||
const indexes = mysql.filter((statement) => statement.includes("INDEX"));
|
||||
|
||||
expect(indexes).not.toHaveLength(0);
|
||||
for (const statement of indexes) {
|
||||
expect(statement).not.toContain("IF NOT EXISTS");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("addColumnSql", () => {
|
||||
it("emits an ALTER per dialect", () => {
|
||||
expect(addColumnSql("sqlite", "demo", definition, "body")).toBe(
|
||||
'ALTER TABLE "p_demo_note" ADD COLUMN "body" text;',
|
||||
);
|
||||
expect(addColumnSql("mysql", "demo", definition, "title")).toBe(
|
||||
"ALTER TABLE `p_demo_note` ADD COLUMN `title` varchar(200) NOT NULL;",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropTableSql", () => {
|
||||
it("drops the prefixed table", () => {
|
||||
expect(dropTableSql("postgres", "demo", definition)).toBe(
|
||||
'DROP TABLE IF EXISTS "p_demo_note";',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTable", () => {
|
||||
it("builds a queryable drizzle table carrying the prefixed name", async () => {
|
||||
const { getTableName, getTableColumns } = await import("drizzle-orm");
|
||||
const table = buildTable("demo", definition) as never;
|
||||
|
||||
expect(getTableName(table)).toBe("p_demo_note");
|
||||
expect(Object.keys(getTableColumns(table))).toContain("userId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adoptTableSql", () => {
|
||||
const adopted = adoptLegacyTable(
|
||||
"fleets",
|
||||
defineTable(
|
||||
"fleets",
|
||||
{ id: id(), name: text().notNull().default("x") },
|
||||
{ indexes: [{ name: "idx_fleets_name", columns: ["id"] }] },
|
||||
),
|
||||
);
|
||||
|
||||
it("creates the legacy table when missing, indexes it, then renames it", () => {
|
||||
const sqlite = adoptTableSql("sqlite", "fleets", adopted);
|
||||
expect(sqlite[0]).toMatch(/^CREATE TABLE IF NOT EXISTS "fleets" \(/);
|
||||
expect(sqlite[1]).toBe(
|
||||
'CREATE INDEX IF NOT EXISTS "idx_fleets_name" ON "fleets" ("id");',
|
||||
);
|
||||
expect(sqlite[2]).toBe('ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";');
|
||||
|
||||
expect(adoptTableSql("postgres", "fleets", adopted).at(-1)).toBe(
|
||||
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";',
|
||||
);
|
||||
});
|
||||
|
||||
it("emits no indexes on MySQL, which has no IF NOT EXISTS for them", () => {
|
||||
const mysql = adoptTableSql("mysql", "fleets", adopted);
|
||||
expect(mysql).toHaveLength(2);
|
||||
expect(mysql[1]).toBe("RENAME TABLE `fleets` TO `p_fleets_fleets`;");
|
||||
});
|
||||
|
||||
it("writes a TEXT default as an expression on MySQL", () => {
|
||||
expect(adoptTableSql("mysql", "fleets", adopted)[0]).toContain(
|
||||
"`name` text NOT NULL DEFAULT ('x')",
|
||||
);
|
||||
expect(createTableSql("sqlite", "fleets", adopted)[0]).toContain(
|
||||
"\"name\" text NOT NULL DEFAULT 'x'",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a definition that adopts nothing, and a table no plugin may adopt", () => {
|
||||
expect(() => adoptTableSql("sqlite", "demo", definition)).toThrow(
|
||||
/does not adopt/,
|
||||
);
|
||||
expect(() => adoptLegacyTable("users", definition)).toThrow(
|
||||
/not a legacy core table/,
|
||||
);
|
||||
});
|
||||
|
||||
it("splits into statements the runner applies one at a time", () => {
|
||||
const joined = adoptTableSql("sqlite", "fleets", adopted).join("\n");
|
||||
expect(splitStatements(joined)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
requireSignedPlugins,
|
||||
verifyPluginArtifact,
|
||||
type TrustedPluginKey,
|
||||
} from "../../plugins/trust.js";
|
||||
|
||||
function keyPair(): { key: TrustedPluginKey; privateKey: crypto.KeyObject } {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const raw = publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
return { key: { id: "test", publicKey: raw, addedIn: "2.9.0" }, privateKey };
|
||||
}
|
||||
|
||||
function signed(buffer: Buffer, privateKey: crypto.KeyObject) {
|
||||
const digest = crypto.createHash("sha256").update(buffer).digest();
|
||||
return {
|
||||
sha256: digest.toString("hex"),
|
||||
signature: crypto.sign(null, digest, privateKey).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("verifyPluginArtifact", () => {
|
||||
const artifact = Buffer.from("a plugin archive");
|
||||
|
||||
it("accepts a file signed by a pinned key", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyPluginArtifact(artifact, sha256, signature, [key])).toEqual({
|
||||
ok: true,
|
||||
keyId: "test",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a signature from any of several pinned keys", () => {
|
||||
const old = keyPair();
|
||||
const current = keyPair();
|
||||
const { sha256, signature } = signed(artifact, old.privateKey);
|
||||
const result = verifyPluginArtifact(artifact, sha256, signature, [
|
||||
current.key,
|
||||
{ ...old.key, id: "old" },
|
||||
]);
|
||||
expect(result).toEqual({ ok: true, keyId: "old" });
|
||||
});
|
||||
|
||||
it("refuses a tampered file", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
const tampered = Buffer.from("a plugin archivE");
|
||||
expect(verifyPluginArtifact(tampered, sha256, signature, [key])).toEqual({
|
||||
ok: false,
|
||||
reason: "sha256 does not match the file",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a tampered file even when the sha256 matches it", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
const { signature } = signed(artifact, privateKey);
|
||||
const tampered = Buffer.from("something else");
|
||||
const sha256 = crypto.createHash("sha256").update(tampered).digest("hex");
|
||||
expect(verifyPluginArtifact(tampered, sha256, signature, [key])).toEqual({
|
||||
ok: false,
|
||||
reason: "signature does not match a trusted key",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a signature from a key that is not pinned", () => {
|
||||
const pinned = keyPair();
|
||||
const other = keyPair();
|
||||
const { sha256, signature } = signed(artifact, other.privateKey);
|
||||
expect(
|
||||
verifyPluginArtifact(artifact, sha256, signature, [pinned.key]).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses garbage and an empty key list", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyPluginArtifact(artifact, sha256, "bm9wZQ==", [key]).ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect(verifyPluginArtifact(artifact, "zz", signature, [key]).ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect(verifyPluginArtifact(artifact, sha256, signature, []).ok).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("trusts no key by default until one is pinned", () => {
|
||||
const { privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyPluginArtifact(artifact, sha256, signature).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireSignedPlugins", () => {
|
||||
it("is on only for the exact value true", () => {
|
||||
const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
expect(requireSignedPlugins()).toBe(true);
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "1";
|
||||
expect(requireSignedPlugins()).toBe(false);
|
||||
if (before === undefined) delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||
else process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = before;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The committed 2.8 fixture still matches rows.ts and still fills every
|
||||
* table a plugin adopts. A new legacy table in LEGACY_TABLE_OWNERS fails here
|
||||
* until it gets fixture rows (npx tsx scripts/build-upgrade-fixture.ts).
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { UPGRADE_FIXTURE_DIR } from "./built-harness.js";
|
||||
import {
|
||||
ENCRYPTED_COLUMNS,
|
||||
RECORDING_FILES,
|
||||
ROWS,
|
||||
} from "../fixtures/upgrade/rows.js";
|
||||
|
||||
const sqlite = new Database(path.join(UPGRADE_FIXTURE_DIR, "db.sqlite"), {
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
afterAll(() => sqlite.close());
|
||||
|
||||
const count = (table: string) =>
|
||||
(
|
||||
sqlite.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get() as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
|
||||
describe("the committed 2.8 upgrade fixture", () => {
|
||||
it("fills every table a plugin adopts", () => {
|
||||
for (const table of Object.keys(LEGACY_TABLE_OWNERS)) {
|
||||
expect(ROWS[table]?.length ?? 0, table).toBeGreaterThan(0);
|
||||
expect(count(table), table).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds exactly the rows in rows.ts", () => {
|
||||
for (const [table, rows] of Object.entries(ROWS)) {
|
||||
// Each user also has a wrapped data key in settings.
|
||||
const extra = table === "settings" ? ROWS.users.length : 0;
|
||||
expect(count(table), table).toBe(rows.length + extra);
|
||||
}
|
||||
});
|
||||
|
||||
it("stores the columns 2.8 encrypted as ciphertext", () => {
|
||||
for (const [table, column] of ENCRYPTED_COLUMNS) {
|
||||
const values = (
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT "${column}" AS value FROM "${table}" WHERE "${column}" IS NOT NULL AND "${column}" <> ''`,
|
||||
)
|
||||
.all() as Array<{ value: string }>
|
||||
).map((row) => row.value);
|
||||
for (const value of values) {
|
||||
expect(FieldCrypto.isEncrypted(value), `${table}.${column}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("ships the recording files its rows point at", () => {
|
||||
for (const relative of Object.values(RECORDING_FILES)) {
|
||||
expect(
|
||||
fs.existsSync(path.join(UPGRADE_FIXTURE_DIR, "files", relative)),
|
||||
relative,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,741 @@
|
||||
/**
|
||||
* The 2.8 to 2.9.0 upgrade, end to end. The committed 2.8 install in
|
||||
* fixtures/upgrade boots through the pre-upgrade backup, core's migrations,
|
||||
* every built plugin's migrations and the data moves, and everything it held
|
||||
* reads back through the plugins' routes, their settings and their ctx. Then
|
||||
* the same data directory boots again and nothing changes.
|
||||
*
|
||||
* TEST_DIALECT=postgres|mysql with TEST_DATABASE_URL runs it against a real
|
||||
* server instead (scripts/upgrade-check.sh sets that up).
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
import {
|
||||
assertPluginsBuilt,
|
||||
bootCore,
|
||||
BUILT_PLUGINS_DIR,
|
||||
UPGRADE_FIXTURE_DIR,
|
||||
type BootedCore,
|
||||
} from "./built-harness.js";
|
||||
import { remoteTestDialect } from "./remote-seed.js";
|
||||
import {
|
||||
FIXTURE_PASSWORD,
|
||||
HOST_AUTH_TYPES,
|
||||
HOSTS,
|
||||
LDAP_PROVIDER_ID,
|
||||
OPERATORS_PERMISSIONS,
|
||||
ROWS,
|
||||
SSO_PROVIDER_ID,
|
||||
SYNCED_ENTITY_TYPES_28,
|
||||
TOTP_BACKUP_CODES,
|
||||
TOTP_SECRET,
|
||||
USER_ROLE_PERMISSIONS_28,
|
||||
USERS,
|
||||
} from "../fixtures/upgrade/rows.js";
|
||||
|
||||
const BOOT_TIMEOUT = 240_000;
|
||||
|
||||
type UserKey = keyof typeof USERS;
|
||||
|
||||
interface Booted {
|
||||
core: BootedCore;
|
||||
app: express.Express;
|
||||
tokens: Record<UserKey, string>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** legacy table -> the p_ table its plugin renamed it to. */
|
||||
function adoptedNames(): Map<string, string> {
|
||||
const names = new Map<string, string>();
|
||||
for (const id of new Set(Object.values(LEGACY_TABLE_OWNERS))) {
|
||||
const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite");
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
const text = fs.readFileSync(path.join(dir, file), "utf8");
|
||||
for (const match of text.matchAll(
|
||||
/ALTER TABLE "([a-z0-9_]+)" RENAME TO "(p_[a-z0-9_]+)"/g,
|
||||
)) {
|
||||
names.set(match[1], match[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
async function boot(dataDir?: string): Promise<Booted> {
|
||||
vi.resetModules();
|
||||
const errors: string[] = [];
|
||||
const error = vi.spyOn(console, "error").mockImplementation((...args) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
let core: BootedCore;
|
||||
try {
|
||||
core = await bootCore(
|
||||
dataDir ? { reuseDataDir: dataDir } : { fixtureDir: UPGRADE_FIXTURE_DIR },
|
||||
);
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
warn.mockRestore();
|
||||
}
|
||||
|
||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||
const auth = AuthManager.getInstance();
|
||||
const tokens = {} as Record<UserKey, string>;
|
||||
for (const [key, userId] of Object.entries(USERS)) {
|
||||
await auth.unlockWithSystemKey(userId);
|
||||
tokens[key as UserKey] = await auth.generateJWTToken(userId, {
|
||||
deviceType: "web",
|
||||
deviceInfo: "upgrade test",
|
||||
});
|
||||
}
|
||||
|
||||
const { mountPluginApi } =
|
||||
await import("../../database/routes/plugin-api-routes.js");
|
||||
const { default: pluginRoutes } =
|
||||
await import("../../database/routes/plugins.js");
|
||||
const { default: userRoutes } =
|
||||
await import("../../database/routes/users.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/users", userRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
mountPluginApi(app);
|
||||
|
||||
return { core, app, tokens, errors };
|
||||
}
|
||||
|
||||
function get(booted: Booted, url: string, as: UserKey = "admin") {
|
||||
return request(booted.app)
|
||||
.get(url)
|
||||
.set("Authorization", `Bearer ${booted.tokens[as]}`);
|
||||
}
|
||||
|
||||
async function settings(
|
||||
booted: Booted,
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
as: UserKey = "admin",
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await get(
|
||||
booted,
|
||||
`/plugins/${pluginId}/settings/${scope}`,
|
||||
as,
|
||||
);
|
||||
expect(response.status, `${pluginId} ${scope} settings`).toBe(200);
|
||||
return response.body.values as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A ctx for a running plugin, the one its own code gets. */
|
||||
async function pluginCtx(pluginId: string) {
|
||||
const { getPluginRuntime } = await import("../../plugins/index.js");
|
||||
const { createPluginContext } = await import("../../plugins/ctx.js");
|
||||
const loaded = getPluginRuntime().loader.get(pluginId);
|
||||
if (!loaded?.handle) throw new Error(`${pluginId} is not running`);
|
||||
return createPluginContext(loaded.manifest, loaded.handle);
|
||||
}
|
||||
|
||||
async function asUser<T>(userId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const { runAsActor } = await import("../../plugins/actor.js");
|
||||
return runAsActor(userId, "asUser", fn);
|
||||
}
|
||||
|
||||
/** Every row of a plugin table, read through that plugin's ctx.db. */
|
||||
async function pluginRows(
|
||||
pluginId: string,
|
||||
table: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const ctx = await pluginCtx(pluginId);
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const client = await ctx.db.client<any>();
|
||||
const query = sql`SELECT * FROM ${sql.identifier(table)}`;
|
||||
if (typeof client.all === "function") return client.all(query);
|
||||
const result = await client.execute(query);
|
||||
return Array.isArray(result) ? result[0] : result.rows;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
/** Bookkeeping counts, compared between the first and second boot. */
|
||||
async function counts(): Promise<Record<string, number>> {
|
||||
const { selectRows } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
const out: Record<string, number> = {};
|
||||
const tables = [
|
||||
"plugin_migrations",
|
||||
"plugin_settings",
|
||||
"user_second_factors",
|
||||
"user_external_identities",
|
||||
"roles",
|
||||
"user_roles",
|
||||
"ssh_data",
|
||||
...adoptedNames().values(),
|
||||
];
|
||||
for (const table of tables) {
|
||||
const [row] = await selectRows<{ n: number | string }>(
|
||||
sql`SELECT COUNT(*) AS n FROM ${sql.identifier(table)}`,
|
||||
);
|
||||
out[table] = Number(row.n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function totpCode(secret: string, at = Date.now()): string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let bits = "";
|
||||
for (const char of secret.replace(/=+$/, "")) {
|
||||
bits += alphabet.indexOf(char).toString(2).padStart(5, "0");
|
||||
}
|
||||
const key = Buffer.from(
|
||||
bits.match(/.{8}/g)!.map((byte) => parseInt(byte, 2)),
|
||||
);
|
||||
const counter = Buffer.alloc(8);
|
||||
counter.writeBigUInt64BE(BigInt(Math.floor(at / 30_000)));
|
||||
const hmac = crypto.createHmac("sha1", key).update(counter).digest();
|
||||
const offset = hmac[hmac.length - 1] & 0xf;
|
||||
const value = (hmac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
|
||||
return String(value).padStart(6, "0");
|
||||
}
|
||||
|
||||
/** Everything a 2.8 install had, checked against a booted 2.9.0. */
|
||||
function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
it("boots without logging an error", () => {
|
||||
expect(current().errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("activates every plugin", async () => {
|
||||
const { getPluginRuntime } = await import("../../plugins/index.js");
|
||||
const notActive = getPluginRuntime()
|
||||
.loader.list()
|
||||
.filter((plugin) => plugin.state !== "active")
|
||||
.map((plugin) => `${plugin.id}: ${plugin.state} ${plugin.lastError}`);
|
||||
expect(notActive).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every row of every adopted table, readable by its plugin", async () => {
|
||||
const names = adoptedNames();
|
||||
for (const [legacy, pluginId] of Object.entries(LEGACY_TABLE_OWNERS)) {
|
||||
const seeded = ROWS[legacy] ?? [];
|
||||
expect(seeded.length, `the fixture fills ${legacy}`).toBeGreaterThan(0);
|
||||
const table = names.get(legacy);
|
||||
expect(table, `${pluginId} adopts ${legacy}`).toBeDefined();
|
||||
// 2.8 kept LDAP directories in sso_providers; the ldap plugin takes them.
|
||||
const expected =
|
||||
legacy === "sso_providers"
|
||||
? seeded.filter((row) => row.type !== "ldap")
|
||||
: seeded;
|
||||
const rows = await pluginRows(pluginId, table!);
|
||||
const ids = rows.map((row) => String(row.id)).sort();
|
||||
expect(ids, `${legacy} -> ${table}`).toEqual(
|
||||
expected.map((row) => String(row.id)).sort(),
|
||||
);
|
||||
}
|
||||
const ldap = await pluginRows("ldap", "p_ldap_providers");
|
||||
expect(ldap.map((row) => String(row.id))).toEqual([
|
||||
String(LDAP_PROVIDER_ID),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the adopted rows back through each plugin's routes", async () => {
|
||||
const checks: Array<[string, string, UserKey?]> = [
|
||||
["/plugin-api/snippets/", "d4-snippet"],
|
||||
["/plugin-api/snippets/shared", "d4-shared-snippet", "alice"],
|
||||
["/plugin-api/fleets/", "d4-fleet"],
|
||||
["/plugin-api/workspaces/", "d4-workspace"],
|
||||
["/plugin-api/network-topology/", "d4-node"],
|
||||
["/plugin-api/automations/", "d4-automation"],
|
||||
["/plugin-api/homepage/items", "d4-widget"],
|
||||
["/plugin-api/homepage/service-links", "d4-link"],
|
||||
["/plugin-api/vault/profiles", "d4-vault"],
|
||||
["/plugin-api/termix-identity/me", "d4-handle"],
|
||||
["/plugin-api/sso/providers", "d4-sso"],
|
||||
["/plugin-api/ldap/providers", "d4-ldap"],
|
||||
[
|
||||
`/plugin-api/ssh-terminal/command-history/${HOSTS.password}`,
|
||||
"d4-history-command",
|
||||
],
|
||||
["/plugin-api/tunnels/presets", "d4-preset"],
|
||||
["/plugin-api/ai/providers", "d4-provider"],
|
||||
["/plugin-api/ai/conversations/1", "d4-message"],
|
||||
["/plugin-api/secret-sources", "d4-source"],
|
||||
["/plugin-api/session-sharing/rooms", "d4-room"],
|
||||
["/plugin-api/webauthn/credentials", "d4-passkey", "passkey"],
|
||||
["/plugin-api/alerts/channels", "d4-channel"],
|
||||
];
|
||||
const failures: string[] = [];
|
||||
for (const [url, marker, as] of checks) {
|
||||
const response = await get(current(), url, as);
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
!JSON.stringify(response.body).includes(marker)
|
||||
) {
|
||||
failures.push(
|
||||
`${url} -> ${response.status} ${JSON.stringify(response.body).slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("moves every host flag into its plugin's host settings", async () => {
|
||||
const booted = current();
|
||||
const host = (id: string) => settings(booted, id, `host/${HOSTS.password}`);
|
||||
expect(await host("ssh-terminal")).toMatchObject({
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: false,
|
||||
enableTerminalToolbar: false,
|
||||
// The look it saved in terminal_config, which took it off the user's.
|
||||
inheritAppearance: false,
|
||||
theme: "dracula",
|
||||
fontSize: 16,
|
||||
cursorStyle: "block",
|
||||
autoTmux: true,
|
||||
sudoPasswordAutoFill: true,
|
||||
localEcho: "on",
|
||||
});
|
||||
expect(
|
||||
await settings(booted, "ssh-terminal", `host/${HOSTS.key}`),
|
||||
).toMatchObject({ inheritAppearance: true, passwordPromptAutoFill: false });
|
||||
expect(await host("session-recording")).toMatchObject({
|
||||
enableSessionRecording: false,
|
||||
});
|
||||
expect(await host("session-sharing")).toMatchObject({
|
||||
allowSessionSharing: false,
|
||||
});
|
||||
expect(await host("file-manager")).toMatchObject({
|
||||
enableFileManager: false,
|
||||
defaultPath: "/srv/d4",
|
||||
scpLegacy: true,
|
||||
});
|
||||
const tunnels = await host("tunnels");
|
||||
expect(tunnels.enableTunnel).toBe(true);
|
||||
expect(JSON.stringify(tunnels.tunnelConnections)).toContain("8080");
|
||||
const web = await host("web-endpoint");
|
||||
expect(web.enableWebUi).toBe(true);
|
||||
expect(JSON.stringify(web.webUiConfig)).toContain("d4-grafana");
|
||||
expect(await host("tmux-monitor")).toMatchObject({
|
||||
enableTmuxMonitor: true,
|
||||
});
|
||||
expect(await host("docker")).toMatchObject({
|
||||
enableDocker: true,
|
||||
containerRuntime: "podman",
|
||||
});
|
||||
expect(await host("ai")).toMatchObject({ enableAiAssistant: true });
|
||||
expect(await host("snippets")).toMatchObject({
|
||||
startupSnippetId: 1,
|
||||
quickActions: [{ name: "d4-uptime", snippetId: 1 }],
|
||||
});
|
||||
expect(await host("wake-on-lan")).toMatchObject({
|
||||
macAddress: "AA:BB:CC:DD:EE:04",
|
||||
broadcastAddress: "10.4.0.255",
|
||||
});
|
||||
expect(await host("host-metrics")).toMatchObject({
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
enabledWidgets: ["cpu", "memory"],
|
||||
});
|
||||
|
||||
const proxmox = await settings(booted, "proxmox", `host/${HOSTS.key}`);
|
||||
expect(proxmox.enableProxmox).toBe(true);
|
||||
expect(JSON.stringify(proxmox)).toContain("pve.d4.example");
|
||||
|
||||
expect(
|
||||
await settings(booted, "remote-desktop", `host/${HOSTS.desktop}`),
|
||||
).toMatchObject({
|
||||
enableRdp: true,
|
||||
enableVnc: true,
|
||||
enableTelnet: true,
|
||||
rdpPort: 3390,
|
||||
vncPort: 5901,
|
||||
telnetPort: 2323,
|
||||
});
|
||||
expect(
|
||||
await settings(booted, "remote-desktop", `host/${HOSTS.rdpOnly}`),
|
||||
).toMatchObject({ enableRdp: true });
|
||||
|
||||
expect(
|
||||
await settings(booted, "vault", `host/${HOSTS.vault}`),
|
||||
).toMatchObject({ profileId: 1 });
|
||||
expect(
|
||||
await settings(booted, "warpgate", `host/${HOSTS.warpgate}`),
|
||||
).toMatchObject({ useWarpgate: true });
|
||||
});
|
||||
|
||||
it("keeps 2.8's defaults on a host that never changed them", async () => {
|
||||
const booted = current();
|
||||
const host = (id: string) => settings(booted, id, `host/${HOSTS.plain}`);
|
||||
expect(await host("file-manager")).toMatchObject({
|
||||
enableFileManager: true,
|
||||
});
|
||||
expect(await host("ssh-terminal")).toMatchObject({
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: true,
|
||||
enableTerminalToolbar: true,
|
||||
inheritAppearance: true,
|
||||
autoTmux: false,
|
||||
passwordPromptAutoFill: true,
|
||||
});
|
||||
expect(await host("session-recording")).toMatchObject({
|
||||
enableSessionRecording: true,
|
||||
});
|
||||
expect(await host("session-sharing")).toMatchObject({
|
||||
allowSessionSharing: true,
|
||||
});
|
||||
expect(await host("docker")).toMatchObject({ enableDocker: false });
|
||||
expect(await host("tmux-monitor")).toMatchObject({
|
||||
enableTmuxMonitor: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps each host's connection and secrets", async () => {
|
||||
const { selectRows } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
const hosts = await selectRows<{
|
||||
id: number;
|
||||
auth_type: string;
|
||||
enable_ssh: unknown;
|
||||
}>(sql`SELECT id, auth_type, enable_ssh FROM ssh_data ORDER BY id`);
|
||||
const byId = new Map(hosts.map((row) => [row.id, row]));
|
||||
for (const [id, authType] of Object.entries(HOST_AUTH_TYPES)) {
|
||||
// Warpgate became a plugin setting on a host with no SSH auth of its own.
|
||||
const want = authType === "warpgate" ? "none" : authType;
|
||||
expect(byId.get(Number(id))?.auth_type, `host ${id}`).toBe(want);
|
||||
}
|
||||
expect(Number(byId.get(HOSTS.rdpOnly)?.enable_ssh)).toBe(0);
|
||||
|
||||
// The connection options in terminal_config moved to core's ssh_options.
|
||||
const [options] = await selectRows<{ ssh_options: string | null }>(
|
||||
sql`SELECT ssh_options FROM ssh_data WHERE id = ${HOSTS.password}`,
|
||||
);
|
||||
expect(JSON.parse(options.ssh_options ?? "{}")).toEqual({
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 4,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "D4", value: "yes" }],
|
||||
});
|
||||
|
||||
// Every 2.8 RDP, VNC and Telnet login reads back through
|
||||
// host_protocol_auth, the only place core reads them from now.
|
||||
const ctx = await pluginCtx("remote-desktop");
|
||||
const resolve = (hostId: number, protocol: string) =>
|
||||
asUser(USERS.admin, () =>
|
||||
ctx.credentials.resolveHostProtocol(hostId, protocol),
|
||||
);
|
||||
expect((await resolve(HOSTS.rdpOnly, "rdp"))?.auth).toMatchObject({
|
||||
authType: "direct",
|
||||
username: "d4-rdp-user",
|
||||
password: "d4-rdp-password",
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "rdp"))?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "d4-rdp-user",
|
||||
password: "d4-rdp-password",
|
||||
fields: { domain: "D4-DOMAIN" },
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "vnc"))?.auth).toMatchObject({
|
||||
authType: "direct",
|
||||
username: "d4-vnc-user",
|
||||
password: "d4-vnc-password",
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "telnet"))?.auth).toMatchObject({
|
||||
authType: "credential",
|
||||
username: "deploy",
|
||||
});
|
||||
|
||||
const logins = await selectRows<{ host_id: number; protocol: string }>(
|
||||
sql`SELECT host_id, protocol FROM host_protocol_auth ORDER BY host_id, protocol`,
|
||||
);
|
||||
expect(
|
||||
logins.map((row) => `${Number(row.host_id)}:${row.protocol}`),
|
||||
).toEqual([
|
||||
`${HOSTS.rdpOnly}:rdp`,
|
||||
`${HOSTS.desktop}:rdp`,
|
||||
`${HOSTS.desktop}:telnet`,
|
||||
`${HOSTS.desktop}:vnc`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers an SSH auth provider for every 2.8 auth type", async () => {
|
||||
const { listSshAuthProviders } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const types = new Set(listSshAuthProviders().map((p) => p.type));
|
||||
for (const authType of new Set(Object.values(HOST_AUTH_TYPES))) {
|
||||
if (authType === "warpgate") continue;
|
||||
expect(types, authType).toContain(authType);
|
||||
}
|
||||
});
|
||||
|
||||
it("plays back every 2.8 recording", async () => {
|
||||
const booted = current();
|
||||
const list = await get(booted, "/plugin-api/session-recording/");
|
||||
expect(
|
||||
(list.body.logs as Array<{ id: number }>).map((log) => log.id).sort(),
|
||||
).toEqual([1, 2]);
|
||||
for (const [id, kind] of [
|
||||
[1, "ssh"],
|
||||
[2, "rdp"],
|
||||
] as const) {
|
||||
const content = await get(
|
||||
booted,
|
||||
`/plugin-api/session-recording/${id}/content`,
|
||||
)
|
||||
.buffer(true)
|
||||
.parse((res, done) => {
|
||||
let text = "";
|
||||
res.on("data", (chunk: Buffer) => (text += chunk.toString()));
|
||||
res.on("end", () => done(null, text));
|
||||
});
|
||||
expect(content.status, `recording ${id}`).toBe(200);
|
||||
expect(content.body).toContain(`d4 ${kind} recording`);
|
||||
}
|
||||
});
|
||||
|
||||
it("puts every moved setting in its plugin's admin and user scope", async () => {
|
||||
const booted = current();
|
||||
expect(await settings(booted, "ssh-terminal", "admin")).toMatchObject({
|
||||
sessionTimeoutMinutes: 45,
|
||||
sessionPersistence: false,
|
||||
commandHistoryForNewHosts: false,
|
||||
imageMaxCount: 7,
|
||||
newHostFontSize: 20,
|
||||
newHostTheme: "nord",
|
||||
newHostAutoTmux: true,
|
||||
});
|
||||
expect(await settings(booted, "ssh-terminal", "user")).toMatchObject({
|
||||
terminalDefaults: { fontSize: 18, cursorBlink: false },
|
||||
customThemes: [expect.objectContaining({ id: "d4-theme" })],
|
||||
commandAutocomplete: true,
|
||||
macros: [expect.objectContaining({ id: "d4-macro", name: "d4 macro" })],
|
||||
});
|
||||
expect(await settings(booted, "snippets", "user")).toMatchObject({
|
||||
confirmExecution: true,
|
||||
foldersCollapsed: false,
|
||||
});
|
||||
expect(await settings(booted, "step-ca", "admin")).toMatchObject({
|
||||
caUrl: "https://ca.d4.example",
|
||||
fingerprint: "d4fingerprint",
|
||||
provisioner: "d4-provisioner",
|
||||
});
|
||||
expect(await settings(booted, "remote-desktop", "admin")).toMatchObject({
|
||||
enabled: true,
|
||||
guacdUrl: "guacd.d4.example:4822",
|
||||
});
|
||||
expect(await settings(booted, "host-metrics", "admin")).toMatchObject({
|
||||
metricsInterval: 40,
|
||||
historyRetentionDays: 14,
|
||||
enabledForNewHosts: false,
|
||||
});
|
||||
expect(await settings(booted, "ai", "admin")).toMatchObject({
|
||||
globallyEnabled: true,
|
||||
});
|
||||
expect(await settings(booted, "session-recording", "admin")).toMatchObject({
|
||||
retentionDays: 3650,
|
||||
});
|
||||
expect(
|
||||
JSON.stringify((await settings(booted, "ai", "admin")).privateEndpoints),
|
||||
).toContain("10.4.0.0/24");
|
||||
expect(await settings(booted, "session-sharing", "admin")).toMatchObject({
|
||||
globallyEnabled: false,
|
||||
});
|
||||
expect(await settings(booted, "telemetry", "admin")).toMatchObject({
|
||||
enabled: false,
|
||||
instanceId: "d4-instance-id",
|
||||
});
|
||||
expect(await settings(booted, "acme-ssl", "admin")).toMatchObject({
|
||||
domain: "termix.d4.example",
|
||||
email: "d4@example.com",
|
||||
});
|
||||
expect(await settings(booted, "tailscale", "admin")).toMatchObject({
|
||||
apiBaseUrl: "https://headscale.d4.example",
|
||||
});
|
||||
expect(await settings(booted, "ai", "user")).toMatchObject({
|
||||
enabled: true,
|
||||
allowReadOnlyCommands: true,
|
||||
});
|
||||
expect(await settings(booted, "remote-desktop", "user")).toMatchObject({
|
||||
colorDepth: "16",
|
||||
resizeMethod: "reconnect",
|
||||
enableDrive: "on",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every secret readable by the plugin that owns it", async () => {
|
||||
const tailscale = await pluginCtx("tailscale");
|
||||
expect(await tailscale.settings.get("apiKey")).toBe("tskey-d4");
|
||||
const acme = await pluginCtx("acme-ssl");
|
||||
expect(JSON.stringify(await acme.settings.getAll("admin"))).toContain(
|
||||
"d4-cloudflare-token",
|
||||
);
|
||||
const ai = await pluginCtx("ai");
|
||||
expect(await asUser(USERS.admin, () => ai.secrets.get("provider:1"))).toBe(
|
||||
"sk-d4-provider-key",
|
||||
);
|
||||
const sources = await pluginCtx("secret-sources");
|
||||
expect(
|
||||
await asUser(USERS.admin, () => sources.secrets.get("source:d4-source")),
|
||||
).toBe("d4-source-token");
|
||||
});
|
||||
|
||||
it("moves every notification channel into alerts, config readable", async () => {
|
||||
const response = await get(current(), "/plugin-api/alerts/channels/1");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
name: "d4-channel",
|
||||
type: "ntfy",
|
||||
usable: true,
|
||||
config: { url: "https://ntfy.d4.example/d4" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every role's permissions", async () => {
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const permissions = PermissionManager.getInstance();
|
||||
for (const permission of OPERATORS_PERMISSIONS) {
|
||||
expect(
|
||||
await permissions.hasPermission(USERS.alice, permission),
|
||||
`operators: ${permission}`,
|
||||
).toBe(true);
|
||||
}
|
||||
for (const permission of USER_ROLE_PERMISSIONS_28) {
|
||||
expect(
|
||||
await permissions.hasPermission(USERS.passkey, permission),
|
||||
`user: ${permission}`,
|
||||
).toBe(true);
|
||||
}
|
||||
expect(await permissions.hasPermission(USERS.admin, "sso.manage")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("still asks a 2.8 TOTP user for a code, and accepts one", async () => {
|
||||
const booted = current();
|
||||
const login = await request(booted.app)
|
||||
.post("/users/login")
|
||||
.send({ username: "d4-totp", password: FIXTURE_PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
expect(login.body.requires_totp).toBe(true);
|
||||
expect(JSON.stringify(login.body.second_factors)).toContain("totp");
|
||||
|
||||
const wrong = await request(booted.app)
|
||||
.post("/users/totp/verify-login")
|
||||
.send({ temp_token: login.body.temp_token, totp_code: "000000" });
|
||||
expect(wrong.status).not.toBe(200);
|
||||
|
||||
const verify = await request(booted.app)
|
||||
.post("/users/totp/verify-login")
|
||||
.send({
|
||||
temp_token: login.body.temp_token,
|
||||
totp_code: totpCode(TOTP_SECRET),
|
||||
});
|
||||
expect(verify.status).toBe(200);
|
||||
|
||||
const again = await request(booted.app)
|
||||
.post("/users/login")
|
||||
.send({ username: "d4-totp", password: FIXTURE_PASSWORD });
|
||||
const backup = await request(booted.app)
|
||||
.post("/users/totp/verify-login")
|
||||
.send({
|
||||
temp_token: again.body.temp_token,
|
||||
totp_code: TOTP_BACKUP_CODES[bootIndex],
|
||||
});
|
||||
expect(backup.status).toBe(200);
|
||||
});
|
||||
|
||||
it("signs a password user without a second factor straight in", async () => {
|
||||
const login = await request(current().app)
|
||||
.post("/users/login")
|
||||
.send({ username: "d4-alice", password: FIXTURE_PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
expect(login.body.requires_totp).toBeFalsy();
|
||||
});
|
||||
|
||||
it("links SSO and LDAP users to their providers", async () => {
|
||||
const { selectRows } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
const links = await selectRows<{
|
||||
user_id: string;
|
||||
provider_id: unknown;
|
||||
}>(sql`SELECT user_id, provider_id FROM user_external_identities`);
|
||||
const byUser = new Map(
|
||||
links.map((row) => [row.user_id, String(row.provider_id)]),
|
||||
);
|
||||
expect(byUser.get(USERS.sso)).toBe(String(SSO_PROVIDER_ID));
|
||||
// The ldap plugin signs in with "ldap:<provider>".
|
||||
expect(byUser.get(USERS.ldap)).toBe(`ldap:${LDAP_PROVIDER_ID}`);
|
||||
});
|
||||
|
||||
it("syncs every entity type 2.8 synced, under the same name", async () => {
|
||||
const { listEntityTypes } = await import("../../plugins/sync-registry.js");
|
||||
const { registerCoreSyncEntities } = await import("../../sync/entities.js");
|
||||
registerCoreSyncEntities();
|
||||
const known = new Set(listEntityTypes());
|
||||
for (const type of SYNCED_ENTITY_TYPES_28) {
|
||||
expect(known, type).toContain(type);
|
||||
}
|
||||
|
||||
const snippets = await pluginRows("snippets", "p_snippets_snippets");
|
||||
expect(snippets.map((row) => row.sync_id)).toContain("d4-sync-snippet");
|
||||
const vault = await pluginRows("vault", "p_vault_profiles");
|
||||
expect(vault.map((row) => row.sync_id)).toContain("d4-sync-vault");
|
||||
});
|
||||
}
|
||||
|
||||
let first: Booted;
|
||||
let firstCounts: Record<string, number>;
|
||||
let second: Booted;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertPluginsBuilt();
|
||||
first = await boot();
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await (second ?? first)?.core.shutdown();
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
describe("a 2.8 install upgraded to 2.9.0", () => {
|
||||
// Only SQLite is backed up by Termix; a server database is the operator's.
|
||||
it.skipIf(remoteTestDialect())(
|
||||
"backed up the 2.8 database before anything touched it",
|
||||
async () => {
|
||||
const backups = path.join(first.core.dataDir, "backups");
|
||||
const dirs = fs
|
||||
.readdirSync(backups)
|
||||
.filter((name) => name.startsWith("pre-plugin-runtime-"));
|
||||
expect(dirs).toHaveLength(1);
|
||||
const copy = fs.readFileSync(path.join(backups, dirs[0], "db.sqlite"));
|
||||
expect(copy.equals(first.core.seed!)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
upgradeChecks(() => first, 0);
|
||||
});
|
||||
|
||||
describe("booting the upgraded install again", () => {
|
||||
beforeAll(async () => {
|
||||
firstCounts = await counts();
|
||||
const dataDir = first.core.dataDir;
|
||||
await first.core.shutdown({ keepDataDir: true });
|
||||
second = await boot(dataDir);
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
it("changes nothing", async () => {
|
||||
expect(await counts()).toEqual(firstCounts);
|
||||
});
|
||||
|
||||
it.skipIf(remoteTestDialect())("takes no second backup", () => {
|
||||
const backups = fs.readdirSync(path.join(second.core.dataDir, "backups"));
|
||||
expect(
|
||||
backups.filter((name) => name.startsWith("pre-plugin-runtime-")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
upgradeChecks(() => second, 1);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* A user's rows in plugin tables travel with their export and come back on
|
||||
* import, found through the table definitions plugins registered rather than
|
||||
* any table name core knows.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
defineTable,
|
||||
encryptedText,
|
||||
id,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => drizzle(sqlite),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { registerTable, resetPluginData } =
|
||||
await import("../../plugins/data.js");
|
||||
const {
|
||||
importUserPluginRows,
|
||||
listUserOwnedTables,
|
||||
readUserPluginRows,
|
||||
writeUserPluginTables,
|
||||
} = await import("../../plugins/user-data.js");
|
||||
const { createTableSql } = await import("../../plugins/table-builder.js");
|
||||
|
||||
// Stands for an adopted table; adoptLegacyTable only accepts real core names.
|
||||
const bookmarks = {
|
||||
...defineTable("bookmark", {
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
path: varchar(200).notNull(),
|
||||
note: text(),
|
||||
token: encryptedText(),
|
||||
}),
|
||||
adopts: "bookmarks_legacy",
|
||||
};
|
||||
const shared = defineTable("shared_thing", { id: id(), label: text() });
|
||||
|
||||
function createTables(target: Database.Database) {
|
||||
target.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
|
||||
target.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
for (const definition of [bookmarks, shared]) {
|
||||
for (const statement of createTableSql("sqlite", "fixture", definition)) {
|
||||
target.exec(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sqlite = new Database(":memory:");
|
||||
createTables(sqlite);
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('u1'), ('u2')");
|
||||
sqlite.exec(`INSERT INTO p_fixture_bookmark (user_id, path, note, token)
|
||||
VALUES ('u1', '/etc', 'config', 'sealed'), ('u2', '/var', null, null)`);
|
||||
registerTable("fixture", bookmarks);
|
||||
registerTable("fixture", shared);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginData();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
describe("plugin user data", () => {
|
||||
it("lists only tables with a user column, without keys or secrets", () => {
|
||||
const [owned] = listUserOwnedTables();
|
||||
expect(listUserOwnedTables()).toHaveLength(1);
|
||||
expect(owned).toMatchObject({
|
||||
pluginId: "fixture",
|
||||
table: "p_fixture_bookmark",
|
||||
legacyName: "bookmarks_legacy",
|
||||
userColumn: "user_id",
|
||||
columns: ["user_id", "path", "note"],
|
||||
});
|
||||
});
|
||||
|
||||
it("reads one user's rows", async () => {
|
||||
expect(await readUserPluginRows("u1")).toEqual({
|
||||
p_fixture_bookmark: [{ user_id: "u1", path: "/etc", note: "config" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips through an export file into another user", async () => {
|
||||
// The export file has the user row, as database.ts writes it first.
|
||||
const file = new Database(":memory:");
|
||||
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
|
||||
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
file.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
expect(await writeUserPluginTables(file, "u1")).toBe(1);
|
||||
|
||||
sqlite.exec("DELETE FROM p_fixture_bookmark");
|
||||
const result = await importUserPluginRows(file, "u2");
|
||||
expect(result).toEqual({ imported: 1, skipped: 0, errors: [] });
|
||||
expect(
|
||||
sqlite.prepare("SELECT user_id, path FROM p_fixture_bookmark").all(),
|
||||
).toEqual([{ user_id: "u2", path: "/etc" }]);
|
||||
|
||||
const again = await importUserPluginRows(file, "u2");
|
||||
expect(again).toMatchObject({ imported: 0, skipped: 1 });
|
||||
});
|
||||
|
||||
it("reads a pre-2.9 export by the legacy table name", async () => {
|
||||
const file = new Database(":memory:");
|
||||
file.exec(
|
||||
"CREATE TABLE bookmarks_legacy (id INTEGER PRIMARY KEY, user_id TEXT, path TEXT, note TEXT)",
|
||||
);
|
||||
file.exec(
|
||||
"INSERT INTO bookmarks_legacy (user_id, path, note) VALUES ('old', '/srv', 'x')",
|
||||
);
|
||||
const result = await importUserPluginRows(file, "u1");
|
||||
expect(result.imported).toBe(1);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, note FROM p_fixture_bookmark WHERE path = '/srv'",
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ user_id: "u1", note: "x" });
|
||||
});
|
||||
|
||||
it("skips imported rows that point at another user or host", async () => {
|
||||
sqlite.exec("DROP TABLE ssh_data");
|
||||
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT)");
|
||||
sqlite.exec(
|
||||
"INSERT INTO ssh_data (id, user_id) VALUES (1, 'u2'), (2, 'u1')",
|
||||
);
|
||||
const grants = defineTable("grant", {
|
||||
id: id(),
|
||||
ownerId: refUser(),
|
||||
granteeId: refUser(),
|
||||
hostId: refHost(),
|
||||
});
|
||||
for (const statement of createTableSql("sqlite", "fixture", grants)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
registerTable("fixture", grants);
|
||||
|
||||
const file = new Database(":memory:");
|
||||
file.exec(
|
||||
"CREATE TABLE p_fixture_grant (id INTEGER PRIMARY KEY, owner_id TEXT, grantee_id TEXT, host_id INTEGER)",
|
||||
);
|
||||
file.exec(`INSERT INTO p_fixture_grant (owner_id, grantee_id, host_id) VALUES
|
||||
('old', 'old', 1), ('old', 'u1', 1), ('old', 'old', 2)`);
|
||||
|
||||
const result = await importUserPluginRows(file, "u2");
|
||||
expect(result).toMatchObject({ imported: 1, skipped: 2 });
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT owner_id, grantee_id, host_id FROM p_fixture_grant")
|
||||
.all(),
|
||||
).toEqual([{ owner_id: "u2", grantee_id: "u2", host_id: 1 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* ctx.ws: the one way a plugin serves WebSockets.
|
||||
*
|
||||
* The properties that matter are the ones the per-plugin ports used to leave
|
||||
* to each plugin: an upgrade is authenticated before any plugin code runs, a
|
||||
* route that no longer exists refuses cleanly, and disabling a plugin actually
|
||||
* closes its live sockets rather than just unrouting new ones.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
validToken: "valid-token",
|
||||
userId: "user-1",
|
||||
granted: new Set<string>(["network:serve"]),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
verifyJWTToken: async (token: string) =>
|
||||
token === state.validToken ? { userId: state.userId } : null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
getUserDataKey: (userId: string) =>
|
||||
userId === state.userId ? Buffer.from("key") : null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
hasCapability: async (
|
||||
_pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => declared.includes(capability) && state.granted.has(capability),
|
||||
}));
|
||||
|
||||
const ws = await import("../../plugins/ws.js");
|
||||
|
||||
const DECLARED = ["network:serve"] as const;
|
||||
|
||||
let server: http.Server | null = null;
|
||||
let baseUrl = "";
|
||||
|
||||
async function startServer(): Promise<string> {
|
||||
const instance = http.createServer((_req, res) => {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
ws.attachPluginWebSockets(instance);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server = instance;
|
||||
instance.listen(0, "127.0.0.1", () => {
|
||||
const { port } = instance.address() as AddressInfo;
|
||||
baseUrl = `ws://127.0.0.1:${port}`;
|
||||
resolve(baseUrl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves with "open" or the HTTP status the upgrade was refused with. */
|
||||
function connect(
|
||||
url: string,
|
||||
protocols: string[] = [],
|
||||
): Promise<{ outcome: "open"; socket: WebSocket } | { outcome: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new WebSocket(url, protocols);
|
||||
socket.once("open", () => resolve({ outcome: "open", socket }));
|
||||
socket.once("unexpected-response", (_req, res) =>
|
||||
resolve({ outcome: res.statusCode ?? 0 }),
|
||||
);
|
||||
socket.once("error", () => resolve({ outcome: 0 }));
|
||||
});
|
||||
}
|
||||
|
||||
function authProtocols(token: string): string[] {
|
||||
return [`termix.jwt.${token}`];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
ws.resetPluginWebSockets();
|
||||
state.granted = new Set(["network:serve"]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
ws.resetPluginWebSockets();
|
||||
if (server) {
|
||||
await new Promise((resolve) => server!.close(() => resolve(undefined)));
|
||||
server = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("ctx.ws routing", () => {
|
||||
it("serves a registered route to an authenticated client", async () => {
|
||||
const seen: string[] = [];
|
||||
ws.registerPluginWsRoute(
|
||||
"sample-plugin",
|
||||
"/socket",
|
||||
({ userId, socket }) => {
|
||||
seen.push(userId);
|
||||
(socket as WebSocket).send("hello");
|
||||
},
|
||||
DECLARED,
|
||||
);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe("open");
|
||||
expect(seen).toEqual([state.userId]);
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
|
||||
it("404s a path nothing registered", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
const result = await connect(`${base}/plugin-ws/sample-plugin/missing`);
|
||||
|
||||
expect(result.outcome).toBe(404);
|
||||
});
|
||||
|
||||
it("leaves non-plugin upgrades to whoever else is listening", async () => {
|
||||
let claimedByCore = false;
|
||||
|
||||
const base = await startServer();
|
||||
// A second listener standing in for a core WS server. If the plugin
|
||||
// handler answered /ssh/websocket/ itself this would never fire, and the
|
||||
// socket would be stolen from core rather than passed on.
|
||||
server!.on("upgrade", (_req, socket) => {
|
||||
claimedByCore = true;
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
await connect(`${base}/ssh/websocket/`);
|
||||
|
||||
expect(claimedByCore).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws authentication", () => {
|
||||
it("rejects an upgrade with no token", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(`${base}/plugin-ws/sample-plugin/socket`);
|
||||
|
||||
expect(result.outcome).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects an upgrade with a bad token", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
authProtocols("nope"),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe(401);
|
||||
});
|
||||
|
||||
it("serves a public route without a token", async () => {
|
||||
ws.registerPluginWsRoute(
|
||||
"sample-plugin",
|
||||
"/guest",
|
||||
({ socket }) => (socket as WebSocket).send("hi"),
|
||||
DECLARED,
|
||||
{ public: true },
|
||||
);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(`${base}/plugin-ws/sample-plugin/guest`);
|
||||
|
||||
expect(result.outcome).toBe("open");
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws optional auth", () => {
|
||||
function register(seen: Array<Record<string, unknown>>) {
|
||||
ws.registerPluginWsRoute(
|
||||
"sample-plugin",
|
||||
"/mixed",
|
||||
(connection) => {
|
||||
seen.push({
|
||||
userId: connection.userId,
|
||||
unlocked: connection.isDataUnlocked(),
|
||||
clientIp: connection.clientIp,
|
||||
requestOrigin: connection.requestOrigin,
|
||||
});
|
||||
(connection.socket as WebSocket).send("hi");
|
||||
},
|
||||
DECLARED,
|
||||
{ public: true, optionalAuth: true },
|
||||
);
|
||||
}
|
||||
|
||||
it("names the user when a valid token came with the upgrade", async () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
register(seen);
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/mixed`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
expect(result.outcome).toBe("open");
|
||||
expect(seen[0]).toMatchObject({ userId: state.userId, unlocked: true });
|
||||
expect(typeof seen[0].clientIp).toBe("string");
|
||||
expect(String(seen[0].requestOrigin)).toMatch(/^http/);
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
|
||||
it("still serves a guest, with no user and nothing unlocked", async () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
register(seen);
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/mixed`,
|
||||
authProtocols("nope"),
|
||||
);
|
||||
expect(result.outcome).toBe("open");
|
||||
expect(seen[0]).toMatchObject({ userId: "", unlocked: false });
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws capability", () => {
|
||||
it("refuses the upgrade when network:serve is not granted", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
state.granted.delete("network:serve");
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws disposal", () => {
|
||||
it("closes live sockets and stops routing on deactivate", async () => {
|
||||
const dispose = ws.registerPluginWsRoute(
|
||||
"sample-plugin",
|
||||
"/socket",
|
||||
() => {},
|
||||
DECLARED,
|
||||
);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
expect(result.outcome).toBe("open");
|
||||
|
||||
const closed = new Promise<number>((resolve) => {
|
||||
if (result.outcome === "open") {
|
||||
result.socket.once("close", (code) => resolve(code));
|
||||
}
|
||||
});
|
||||
|
||||
dispose();
|
||||
|
||||
// A live terminal must not outlive the plugin that owns it.
|
||||
expect(await closed).toBeGreaterThan(0);
|
||||
|
||||
const afterDispose = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
expect(afterDispose.outcome).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws raw upgrades", () => {
|
||||
it("authenticates before handing the socket to the plugin", async () => {
|
||||
const handled: string[] = [];
|
||||
ws.registerPluginWsUpgrade(
|
||||
"sample-plugin",
|
||||
"/raw",
|
||||
(_request, socket, _head, userId) => {
|
||||
handled.push(userId);
|
||||
(socket as { destroy: () => void }).destroy();
|
||||
},
|
||||
DECLARED,
|
||||
);
|
||||
|
||||
const base = await startServer();
|
||||
|
||||
const unauthenticated = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/raw`,
|
||||
);
|
||||
expect(unauthenticated.outcome).toBe(401);
|
||||
// The plugin never saw the unauthenticated attempt.
|
||||
expect(handled).toEqual([]);
|
||||
|
||||
await connect(
|
||||
`${base}/plugin-ws/sample-plugin/raw`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
expect(handled).toEqual([state.userId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePluginWsUrl", () => {
|
||||
it("only claims well-formed plugin socket paths", () => {
|
||||
expect(ws.parsePluginWsUrl("/plugin-ws/docker/console")).toEqual({
|
||||
pluginId: "docker",
|
||||
path: "/console",
|
||||
});
|
||||
expect(ws.parsePluginWsUrl("/plugin-ws/docker/console?x=1")).toEqual({
|
||||
pluginId: "docker",
|
||||
path: "/console",
|
||||
});
|
||||
expect(ws.parsePluginWsUrl("/ssh/websocket/")).toBeNull();
|
||||
expect(ws.parsePluginWsUrl("/plugin-ws/docker")).toBeNull();
|
||||
expect(ws.parsePluginWsUrl(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws clashes", () => {
|
||||
it("refuses the same path twice", () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/twice", () => {}, DECLARED);
|
||||
expect(() =>
|
||||
ws.registerPluginWsRoute("sample-plugin", "/twice", () => {}, DECLARED),
|
||||
).toThrow(/already registered/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user