mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
release-2.7.0 (#1264)
* feat: redesign host/credential sidebars with synced preferences and manual drag-to-reorder * chore: run format * chore(deps-dev): bump @types/pg in the dev-patch-updates group (#1162) Bumps the dev-patch-updates group with 1 update: [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg). Updates `@types/pg` from 8.20.0 to 8.20.3 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pg) --- updated-dependencies: - dependency-name: "@types/pg" dependency-version: 8.20.3 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-dev): bump the dev-minor-updates group with 4 updates (#1163) Bumps the dev-minor-updates group with 4 updates: [react-hook-form](https://github.com/react-hook-form/react-hook-form), [react-icons](https://github.com/react-icons/react-icons), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `react-hook-form` from 7.79.0 to 7.84.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.79.0...v7.84.0) Updates `react-icons` from 5.6.0 to 5.7.0 - [Release notes](https://github.com/react-icons/react-icons/releases) - [Commits](https://github.com/react-icons/react-icons/compare/v5.6.0...v5.7.0) Updates `typescript-eslint` from 8.61.1 to 8.66.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/typescript-eslint) Updates `vite` from 8.0.16 to 8.2.0 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/create-vite@8.2.0/packages/vite) --- updated-dependencies: - dependency-name: react-hook-form dependency-version: 7.84.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-icons dependency-version: 5.7.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.66.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: vite dependency-version: 8.2.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-patch-updates group with 3 updates (#1164) Bumps the prod-patch-updates group with 3 updates: [jose](https://github.com/panva/jose), [js-yaml](https://github.com/nodeca/js-yaml) and [nanoid](https://github.com/ai/nanoid). Updates `jose` from 6.2.7 to 6.2.8 - [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.7...v6.2.8) Updates `js-yaml` from 5.2.2 to 5.2.3 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.2.2...5.2.3) Updates `nanoid` from 6.0.0 to 6.0.1 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/6.0.0...6.0.1) --- updated-dependencies: - dependency-name: jose dependency-version: 6.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: js-yaml dependency-version: 5.2.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: nanoid dependency-version: 6.0.1 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> * chore(deps): bump undici in the prod-minor-updates group (#1165) Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.9.0 to 8.10.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... 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 major-updates group with 2 updates (#1166) Bumps the major-updates group with 2 updates: [@types/better-sqlite3](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/better-sqlite3) and [jsdom](https://github.com/jsdom/jsdom). Updates `@types/better-sqlite3` from 7.6.13 to 9.6.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/better-sqlite3) Updates `jsdom` from 29.1.1 to 30.0.1 - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1) --- updated-dependencies: - dependency-name: "@types/better-sqlite3" dependency-version: 9.6.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: jsdom dependency-version: 30.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: stop resyncAutoIncrement failing on tables without an id column (#1173) The Postgres branch asked pg_get_serial_sequence(table, 'id') about every table a fixture had inserted into. That function raises 42703 when the column does not exist, rather than returning null, so any seed touching a table keyed on something else took down the fixture. host_sidebar_preferences is keyed on user_id and has no id at all, which is why the Postgres job on dev-2.7.0 fails for every pull request. Drive the lookup from information_schema so a missing id column yields no row instead of an error. A text primary key still returns a null sequence and is still skipped, as before. * chore: install the git hooks that were already configured (#1174) husky, lint-staged, commitlint and their config have been in the repo since v1.8.0 (#429): .husky/pre-commit runs lint-staged, .husky/commit-msg runs commitlint, the lint-staged globs are in package.json and the commitlint rules in .commitlintrc.json. None of it has ever run. husky only takes effect once it sets core.hooksPath, and that happens in the prepare lifecycle script, which the package did not define -- so every clone installed the tooling and left the hooks unwired. That is why formatting keeps failing in CI rather than locally: three of the four open pull requests fail lint-and-build on prettier alone, touching between one and five files each, and the check is the first place anyone finds out. prepare falls back to true so a checkout without a .git directory cannot break installation. The Docker build passes --ignore-scripts, so it never runs this at all. Also pin the Prettier extension to the repo's own copy via prettier.prettierPath, and let .vscode/settings.json out of .gitignore so it applies to everyone. The extension bundles its own prettier otherwise, which formats to a different version's rules than the one CI enforces. * fix: derive the ssh_credentials rebuild from the live schema (#1172) The startup rebuild that drops the old username NOT NULL constraint restated the table's columns as a literal and then copied rows with INSERT INTO temp SELECT <every live column>. The table has gained columns since that literal was written — cert_public_key, pin, sort_order and sync_id are all added by addColumnIfNotExists before the rebuild runs — so the destination was narrower than the source. SQLite rejected the INSERT on a column count mismatch, the error was swallowed as a warning, and the constraint survived every restart. Read the CREATE TABLE statement back from sqlite_master and rewrite just the table name and the username constraint, so the replacement table cannot fall behind the real one. Copy rows by explicit column name rather than positionally, and replay the table's indexes, which DROP TABLE would otherwise take with it along with the sync_id uniqueness. * fix: make audit_logs.user_id nullable on fresh SQLite installs (#1171) The audit trail is meant to outlive the account it belongs to: deleting a user nulls user_id and keeps username for attribution. schema.ts, the Drizzle migrations and AuditLogRepository.anonymizeByUserId were all written against that, but the runtime bootstrap still created user_id TEXT NOT NULL. A second CREATE TABLE IF NOT EXISTS further down migrateSchema() had the correct nullable column, but it can never run — the primary bootstrap has already created the table, so IF NOT EXISTS is a no-op. Every fresh install therefore got the old constraint, and user deletion failed with "NOT NULL constraint failed: audit_logs.user_id" for any account that had logged in at least once, via both the admin delete path and the OIDC account-link cleanup. Fix the primary bootstrap, and rebuild the table on existing databases using the same pattern already used for ssh_credentials.username, since SQLite cannot ALTER a column. * fix: key the sync upsert on the row it just looked up (#1175) A sync push locates the stored row twice -- once to decide insert vs update, once to write it -- and the two lookups were spelled out separately. Only the read knew about singleton entities; the write always keyed on table.id. userPreferences is the only singleton, and user_preferences is the one synced table with no id column: its primary key is user_id. table.id was therefore undefined, and drizzle emitted a comparison with nothing on its left: ( = ? and "user_preferences"."user_id" = ?) The insert branch was unaffected, so the first push of preferences succeeded and every push after it -- the steady state -- failed with SqliteError: near "=": syntax error. Preference sync never converged, and both sides ship the same handler, so the desktop's embedded backend failed identically. Extract the lookup into locateSyncRow() and use it for the read, the update and the tombstone delete, so the three cannot drift apart again. The tombstone path already handled singletons correctly; it now shares the one expression rather than keeping a third copy of it. * fix: refuse an SSH connection whose host id resolves elsewhere (#1176) A client identifies a host by the numeric row id of the database it is displaying. With the desktop connection origin set to "Remote server", that id is resolved against the sync server's ssh_data instead, and the two autoincrement sequences need not line up -- they diverge as soon as each side accumulates inserts and deletes in a different order. resolveHostById() then returns whichever row owns that id here, and the handler takes the address, the credentials, the jump hosts and the stored host key from it. The session opens on a machine the user did not pick, while the host list, host details and export all keep showing the right one. Commands run on the wrong server, a host key mismatch is reported for the wrong reason, and anything typed at the prompt goes to the wrong place. Compare the resolved address against the one the client sent, and refuse when they disagree. Checking at the point the row is loaded covers every use of it rather than each site separately. Addresses are compared with brackets stripped and casing folded, so an IPv6 literal or a hostname written differently is not treated as a different machine; when the server has no address stored, the client's own details are used as before. This stops the wrong-machine session. It does not make delegated connections work when the ids have drifted -- that needs the host to be addressed by syncId across the boundary, which the connection protocol does not currently carry. * fix: refuse SFTP and Docker console on a mismatched host id too (#1177) The wrong-machine guard added for SSH covered one of the paths that resolve a client-supplied host id against this server's ssh_data. The file manager and the Docker console take the same id from the same client and dial whatever row owns it here. The file manager then browses, edits and deletes files on that machine, and the Docker console attaches to its daemon -- both while the UI shows the host the user actually picked. Reuse hostAddressMismatch at each point the row is loaded. The two file manager sites sit inside "failed to resolve credentials, carry on" handlers, so the refusal is a distinct error type those catches rethrow; swallowing it would resume the connection this is meant to stop. The Docker console reports over its socket, as it does for every other refusal. The user-facing wording now lives next to the check instead of being written out at each site. Still uncovered, and not fixable this way: file-manager's transfer session, jump-host-chain and the proxmox routes resolve an id with no client-supplied address to compare it against. Those need the host to be addressed by syncId across the boundary. * feat: address hosts by syncId when a connection is delegated (#1178) A numeric host id belongs to the database that produced it. The desktop app lists hosts from its embedded database and names them by row id, so when a connection is delegated to a sync server that id is resolved against a different table, whose autoincrement sequence has no reason to agree. The row it lands on is a different machine, and it supplies the address, the credentials, the jump hosts and the stored host key. #1176 and #1177 made that refuse rather than connect. Refusing is right, but it leaves "Remote server" unusable once the ids have drifted, which is the state the reporter was in. syncId already names a host identically on both sides -- remote sync relies on it, ssh_data.sync_id is unique, and the API already returns it. It just never reached the backend: hostToSSHHost() builds its result field by field and dropped it. Carry it through, and resolve with it when it is present: resolveHostBySyncId(syncId, userId) // translate, then reuse -> findHostIdBySyncId(syncId) // this database's own row id -> resolveHostById(hostId, userId) // permissions, decryption, audit The translation is deliberately not scoped to a user -- sync_id is unique across the table and a shared host belongs to someone else -- so access stays with the permission check in the id-based path, which the new tests cover. An unknown syncId resolves to nothing rather than falling back to the numeric id: an unknown host is precisely where guessing picks the wrong machine. Clients that send no syncId are unchanged, address comparison included, so an older desktop keeps its safety net instead of breaking. * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix: keep already-shared hosts sharing their SSH authentication (#1179) Sharing a host used to hand the owner's SSH authentication to the recipient unconditionally. 2.6.1 put that behind ssh_data.share_ssh_auth, added as NOT NULL DEFAULT 0. Existing rows took the default, so every host shared before the upgrade stopped supplying credentials the moment the column appeared. The snapshot in collectProtocolSnapshots() is guarded by host.shareSshAuth, so nothing was captured; resolveRecipientSharedHostAuthentication() then fell through to "required" and the recipient got "No valid authentication method provided" on a host that had worked the day before. Downgrading to 2.6.0 restored it, since that code has no such column to consult. Backfill the flag for hosts that already appear in host_access. That is where the previous behaviour was in effect and where the owner had already agreed to share; hosts nobody has shared keep the new default and stay off until their owner shares them. Guarded by a settings key so it runs once. Without that, an owner who turns sharing back off would have it turned on again by the next restart. * fix: let a single credential disable 2FA again (#1180) The disable dialog has one field, labelled "Enter TOTP code or password", and its caller passes that value as disableTOTP(input) -- so it arrives as `password` with `totp_code` undefined. That call has been unchanged since v2.3.0. 2.5.1 changed the route to require both: if (!totp_code || (!userRecord.isOidc && !password)) -> 400 replacing `const credential = password || totp_code`. The first check has rejected every attempt since, whatever the user typed, so nobody has been able to turn 2FA off -- the client reports the generic "Failed to disable 2FA", which hides which check failed. Take one credential again and try it as a TOTP code, a backup code, then the account password. verifyTotpReauth still refuses the password itself, so that comparison stays in the route; an OIDC user has no password hash and reaches neither. The backup-codes route has the same shape but no caller in the UI -- its codes are returned when TOTP is enabled -- so it is left alone rather than changed blind. * fix: attach user-managed CA certificates over SFTP too (#1181) opkssh-cert-auth.ts exports two helpers that end in the same _applyCertToConnection: setupOPKSSHCertAuth, and setupCACertAuth for user-managed CA-signed -cert.pub files. The file manager called the first one twice and the second one never. So a host whose key is paired with a CA-signed certificate authenticated in a terminal and failed over SFTP, while OPKSSH certificates -- going through the other helper -- worked in both. The file manager was not missing certificate support in general; it was missing one of the two paths into it. The connection also never carried the certificate to begin with: cert_public_key was not among the fields copied into resolvedCredentials, so both places that build an SFTP connection now read it and attach it where the private key is prepared -- the dedicated transfer session and the main connect route. An unusable certificate is logged and skipped rather than failing the connection. The key alone may still be accepted, which is what happened while this was not wired up at all, and turning that into a hard failure would break setups that currently work. Reported in #1160 with the call-site asymmetry already traced; the reporter noted they could not confirm the link to their failure, having moved off SSH CAs. The asymmetry is real either way and reproduces the symptom exactly. * fix: authenticate the desktop Docker console WebSocket (#1182) The console WS opted out of the query token: buildOriginWsUrl({ ..., includeLocalJwt: false }) leaving it with no credential at all on the desktop. The browser WebSocket API cannot set an Authorization header, and while Electron's main process injects a remembered JWT cookie, it requires an exact origin match -- the cookie belongs to the API origin (localhost:30001) while the console connects to 127.0.0.1:30009, so nothing is attached. The backend then closes the handshake with 1008 before it logs anything, which is why the log has no docker-console entries while stats and logs polling keep succeeding on the same host. The web build is unaffected: it connects same-origin and its cookie is sent normally. Drop the opt-out so the console carries the local JWT like the SSH terminal does -- the same token, the same query parameter, and the backend already reads it there. Guacamole passes includeLocalJwt: false too, but rdp/vnc/telnet always resolve to "remote", so that call never reaches the local branch. * fix: use getClientIp in getRequestMeta for correct audit-log IPs (#1183) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: use getClientIp in getRequestMeta for correct audit-log IPs getRequestMeta had near-duplicate, strictly worse forwarded-header logic: the array branch didn't split/trim, there was no socket-peer fallback, and it returned "" instead of "unknown". Delegate to getClientIp so the audit trail gets the same correctness as the terminal login-alert path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * feat: add terminal image handoff (#1170) * chore: sync Crowdin translations * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * feat: add terminal image handoff Add authenticated browser upload and clipboard image handoff for terminal agents. Normalize images through Sharp, enforce storage and request limits, preserve host-visible paths, and provide a stable three-button terminal toolbar. * docs: document terminal image handoff deployment --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix(desktop): stop suppressing the update prompt, and make the version badge reachable (#1167) * fix(desktop): stop suppressing the update prompt for users who need it The startup update modal stored its dismissal under the local app version rather than the remote version being offered, and the up-to-date branch wrote that key with no user interaction at all. A user who launched while current had their own version recorded; once the next release shipped, `dismissedVersion === currentVersion` still held and the modal was skipped on every launch. It reappeared only after the user had already updated -- the inverse of what it is for. Present since v2.3.0. Key the dismissal on the offered remote version instead. The change is backward compatible: an existing key holding 2.6.0 compares unequal against a remote 2.6.1, so affected installs are prompted on their next launch. When the check itself fails there is no remote version, so nothing is recorded and no future prompt is suppressed. That left the version badge as the only remaining signal, and it was an inert span on both surfaces that render it -- the profile panel and the dashboard stats bar -- even though the `getVersionInfo()` response it is built from already carries `latest_release.html_url`. Extract the duplicated badge into `components/version-badge.tsx` and make the update case a link to the release, with an accessible name that says where it goes. The beta and stable cases stay inert. `getVersionInfo()` returned `Record<string, unknown>`, so the release URL was unreachable without a cast; give it a `VersionInfo` type that keeps an index signature, since `SystemOverviewWidget` reads `updateAvailable` off the same response. * test: cover the read that actually reaches the badge The extracted VersionBadge is unit-tested, but the line that decides whether it ever receives a URL -- pulling `latest_release.html_url` out of the version response -- was duplicated at both call sites and asserted nowhere. A wrong property there compiles (the response type keeps an index signature) and every existing test still passes. Give it a name, `releaseUrlFrom`, use it from both surfaces, and test it: the happy path, a response with no release, a release with no URL, and a missing response, since the caller's fetch can reject. Empty string is the contract the badge reads as "nothing to link to", so it stays an inert span rather than rendering a dead anchor. * docs: state the index signature's real reason The comment claimed the version endpoint carries fields beyond the typed ones, citing `updateAvailable`. It does not -- `GET /version` returns status, localVersion, version, remoteVersion, latest_release, cached and cache_age, and nothing else. SystemOverviewWidget reads `updateAvailable` off it regardless, which is why the permissive index signature has to stay, but that is a stale read rather than an undocumented field. Say so accurately. * Send alerts in Discord channels with Webhooks (#1158) * feat(utils): add discord webhook sender Add a utility to send alert embeds to Discord webhooks. * fix(utils): validate DNS and use global fetch for outbound requests Prevent private destination access and rely on global fetch after DNS validation. * chore(logger): include extra context in logs Show additional sanitized context entries for clearer diagnostics. * feat(alerts): support discord channel type in routes and engine Accept discord channels and route alerts to the Discord sender. * feat(ui): add Discord option to notification channel dialog Allow creating/editing Discord webhook channels with username/avatar. * fix(ui/api): accept structured config payload for notification channels Allow the client to pass structured config objects (or strings) when creating/updating channels. * chore: sync Crowdin translations * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * chore: add url to SENSITIVE_FIELDS for discord url * fix: enforce SSRF protection on outbound fetches Use `undici.fetch` with the custom DNS lookup hook to ensure the validated DNS resolution is the one used for the connection. Fix DNS lookup/address validation bugs and add coverage for private, public and invalid addresses, including the resolution issue affecting Discord endpoints. * chore: prettier format * fix: validate all DNS addresses and close dispatcher * fix DNS lookup validation and callback handling * update safe outbound fetch tests * ensure created dispatcher is properly closed * chore: remode url from SENSITIVE_FIELDS for other logs --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix tmux UTF-8 path handling (#1157) Co-authored-by: Carl <scarlettme@qq.com> * chore: update package lock * chore: update gitnore * fix: [BUG] (#1049) https://github.com/Termix-SSH/Support/issues/1049 * fix: test commitlint path fix (#1021) * fix: SGR mouse-tracking escape codes printed as text (#1023) * fix: quote $1 in commit-msg hook so it works from git worktrees * fix: [BUG] could not connect to the database (#1057) https://github.com/Termix-SSH/Support/issues/1057 * fix: [BUG] VNC connect macOS screen sharing failed (#1063) https://github.com/Termix-SSH/Support/issues/1063 * fix: [BUG] Meta key (#1075) https://github.com/Termix-SSH/Support/issues/1075 * fix: [BUG] Remote sync doesn't work with Termix behind nginx proxy (#1085) https://github.com/Termix-SSH/Support/issues/1085 * fix: [BUG] webhook not working (#1080) https://github.com/Termix-SSH/Support/issues/1080 * fix: [BUG] First server sync doesn't refresh UI (#1084) https://github.com/Termix-SSH/Support/issues/1084 * fix: [BUG] How to enable SSL using custom certificate (#1083) https://github.com/Termix-SSH/Support/issues/1083 * fix: [BUG] Sudo Password Auto-fill Persistance (#1098) https://github.com/Termix-SSH/Support/issues/1098 * feat: [FEATURE] Expand Snippets Function (#1031) https://github.com/Termix-SSH/Support/issues/1031 * feat: [FEATURE] (#1055) https://github.com/Termix-SSH/Support/issues/1055 * feat: [FEATURE] Support for Headscale API Keys (hskey prefix) and Custom API Endpoints (#1013) https://github.com/Termix-SSH/Support/issues/1013 * feat: [FEATURE] Allow paste on non https (#1026) https://github.com/Termix-SSH/Support/issues/1026 * feat: be-azerty layout (#1073) https://github.com/Termix-SSH/Support/issues/1073 * feat: Keyboard shortcuts to move between open tabs (#1069) https://github.com/Termix-SSH/Support/issues/1069 * feat: Session Logs as a downloadable text file (#1058) https://github.com/Termix-SSH/Support/issues/1058 * fix: persist and auto-fill saved SSH and sudo passwords * fix: persist docker runtime selection and docker manager UI issues * feat: Allow excluding specific mounts from disk usage metrics (#1046) https://github.com/Termix-SSH/Support/issues/1046 * feat: Expand Snippets Function (#1031) https://github.com/Termix-SSH/Support/issues/1031 * chore: restore the prettier baseline on dev-2.7.0 (#1185) Five files on dev-2.7.0 do not match prettier, so `npx prettier --check .` fails and takes lint-and-build with it — on every pull request, whatever it changes. Formatting only, produced by `npx prettier --write` on exactly the files the check names. No logic touched: tsc passes for both configs, backend 148 files / 1106 tests and UI 71 files / 479 tests all pass. * test: keep the tmux escaping test runnable on Windows (#1184) The escaping check ran its command through /bin/sh. That binary does not exist on Windows, and Windows is a supported platform for the desktop app, so `npm test` fails there on a test about string quoting. CI is ubuntu-only and would never see it. Assert the escaped string directly, which covers the rule on every platform, and keep the round trip through a real shell as a separate case guarded by platform -- it is the stronger evidence where a shell exists. * chore: drop the unreachable table probes from migrateSchema (#1186) Eleven blocks in migrateSchema() guarded a CREATE TABLE IF NOT EXISTS behind SELECT id FROM <table> LIMIT 1, for tables the primary bootstrap had already created earlier in the same startup. The probe could not throw, so the catch never ran. Two of those unreachable copies had drifted from the definition actually in use. sessions had lost ON DELETE CASCADE, and session_recordings still carried user_id TEXT NOT NULL with ON DELETE CASCADE and no username -- the shape from before audit trails were made to outlive the account. They would have taken effect had anything ever reordered startup. Kept, because they are not the same thing: - blocks whose catch runs ALTER TABLE ADD COLUMN. CREATE TABLE IF NOT EXISTS is a no-op on a table that exists, so a database created before a column was added still needs the ALTER. Those probe a column, not a table. - blocks that are a table's only creation point. - the user_open_tabs block, which is a data migration; its SELECT is a precondition, not a probe. Deletion only, no behaviour change. * fix: repair the frontend type-check and clear the 299 errors behind it (#1189) * fix: repair frontend type-check configuration and the errors it exposed The root tsconfig.json is solution-style with "files": [], so the `npx tsc --noEmit` that CI runs compiles nothing at all. Frontend types have therefore never been checked, and 299 errors had accumulated behind that no-op. This clears just over half of them; nothing here changes runtime behaviour. Configuration: - "@/types" resolved through the "@/*" fallback to src/ui/types, which does not exist. Added an explicit mapping to src/types/index.ts. - src/vite-env.d.ts sits outside the include list, so import.meta.env and the ?url import suffix were unknown. Added. - src/ui/types/ held a single file, keybindings.ts, while every other shared type lives in src/types/. Six modules imported it as "@/types/keybindings" and silently resolved to nothing. Moved. Type definitions that had fallen behind the code: - guacamoleConfig and terminalConfig were Record<string, unknown> in ui-types while the editor read concrete fields off them. Both now use the real interfaces; GuacamoleConfig is extracted from its inline definition in guacamole-api.ts so the two cannot drift again. - customThemeColors and TerminalTheme["colors"] described the same object with different optionality. Aligned. - FileWindow declared its own SSHHost whose authType was "password" | "key", which no longer matches the eight the app supports. - connectSSH and listSSHFiles returned Record<string, unknown>, so every field the callers destructured arrived as unknown. - AxiosRequestConfig and AxiosResponse were used without being imported. Also adds asHttpError() for the handful of catch blocks that reached into an unknown binding, and narrows the Host | HostFolder comparator and the RailItem union at the points where the discriminant was not carrying. Note: dbHealthMonitor.reportDatabaseError was being called with a second argument it does not accept, so the authenticated-or-not flag was already being discarded at runtime. Dropped the argument to match the signature; whether that flag was meant to gate the report is worth a separate look. * fix: clear more of the frontend type-check baseline Continues the previous commit; 140 errors down to 70. Three of these were real defects rather than missing annotations. Defects: - DashboardTab counted active tunnels by comparing status to "CONNECTED", but CONNECTION_STATES.CONNECTED is "connected" and that is what the tunnel manager emits, so the count was always zero. Now compares against the constant. - QuickActionsCard requires isAdmin and gates a block of admin-only actions on it, but neither call site passed it — those actions never rendered. Both call sites also passed isAdmin to HostStatusCard, which does not accept it; the prop had evidently been moved and the call sites missed. - The host editor stores jump host ids as strings and sent them straight to an API typed for numbers. Backend host lookups compare against an integer column, which a string does not match on Postgres or MySQL. Converted. Types brought in line with the data: - Host and HostData were missing hasPassword, hasSudoPassword, sortOrder, instanceId, connectionOrigin, vaultProfileId, syncId, and the "vault" authType; TabContextTab was missing the "tunnel" tab, which TabContext already branched on. - statsConfig and terminalConfig used inline shapes that had drifted from StatsConfig and TerminalConfig. Both now reference the real interfaces; excludedMounts, which the editor reads, was added to StatsConfig. - downloadSSHFile, generateKeyPair and generatePublicKeyFromPrivate all returned Record<string, unknown> while callers read named fields. - The Guacamole declarations were missing Keyboard.reset, Client.onfile, InputStream.sendAck, Status.Code and BlobReader, all already in use. - NetworkTopologyNode/Edge could not be discriminated, though the graph code tells them apart by testing for source/target. ProxyNode.type is now 4 | 5 | "http" | "socks4" | "socks5". The editor writes the string spellings while proxy-helper.ts tests for "http" and casts everything else to 4|5 before handing it to the socks client, so a chained proxy reaches it as "socks5" rather than 5. Typed as what is actually stored; reconciling the two spellings needs a migration decision and is left alone here. * fix: continue clearing the frontend type-check baseline 70 errors down to 44. Dead configuration removed: - Terminal set terminal.options.bellStyle on xterm, which dropped the option in v5. The host editor still exposes the setting and stores it; it has simply had no effect on the terminal since that upgrade. Making the bell work again means handling the onBell event and is left alone. - CodeEditor passed scrollPastEnd to basicSetup, which has no such option. - FileManager passed an id to openWindow, which assigns its own and discards what it is given — the component was already being rendered under a different id than the one the caller held. Widgets that were registered but unreachable: - DockerActivityWidget and SshQuickConnectWidget register under "docker_activity" and "ssh_quick_connect", neither of which was in WidgetTypeId, and both referenced config interfaces that did not exist. Added the ids and the two interfaces, inferred from their edit forms and defaultConfig. More endpoints given their real return types: getRecentFiles, getPinnedFiles, getFolderShortcuts (arrays, not records), downloadSSHFile, copySSHItem, generateKeyPair, generatePublicKeyFromPrivate and getSnippets. parseGuacamoleConfig() handles the host row carrying guacamoleConfig either parsed or as raw JSON, which GuacamoleApp was reading fields off directly. TerminalHostConfig was missing name, which it reads for the activity log. * fix: continue clearing the frontend type-check baseline 44 errors down to 17. Host and AuditLog are now type aliases rather than interfaces. An interface has no implicit index signature, so neither could be assigned to the `[key: string]: unknown` shapes that TerminalHostConfig, HostMetricsTab's HostConfig and several helpers declare — eight errors came from that alone. More dead configuration: - i18n passed checkWhitelist to the language detector, which no longer has that option; supportedLngs already covers it. - SSHAuthDialog passed scrollPastEnd to basicSetup, same as CodeEditor. - AudioPreview's onLoadedMetadata never fired: react-h5-audio-player spells the prop onLoadedMetaData. - MarkdownRenderer destructured `inline` from code(), which react-markdown removed in v9, so the flag was always undefined and every inline span took the block branch when it happened to carry a language class. Now derived from whether a className is present at all. - SnippetsPanel put a title prop on a lucide icon, which does not forward it; changed to aria-label so the hint is actually reachable. updateHostConfig in TabContext replaced tab.hostConfig wholesale with the six-field literal it receives, dropping everything else the tab held about the host. It now merges onto the existing config. Also: getReleasesRSS, getUserAlerts and getVersionInfo have real return types (UpdateLog kept private copies of two of them, and VersionInfo was missing `version`, which the endpoint sends and the panel renders); wakeOnLan and vncCredentialId get the numeric ids they are typed for; and the tmux formatter takes i18next's TFunction instead of a hand-written signature it does not satisfy. * fix: clear the last frontend type errors and make CI actually run the check Baseline is now zero, so the check can be turned on. `npx tsc --noEmit` — what CI ran and what `npm run type-check` was — compiles nothing: the root tsconfig.json is solution-style with "files": [], and plain tsc does not follow project references. Both are now `tsc -b`, which builds tsconfig.app.json and tsconfig.node.json. Verified by planting a type error and watching the command fail. Last defects in this batch: - patchOpenTab could not carry hostId, so quick-connect's "save this host and attach the tab to it" call was passing a field excluded from the type all the way down. The column exists and updateForUser spreads whatever it receives, so the write worked; only the types disagreed. Widened front to back. - The file-comparison window opened without x, y, width or height — every other openWindow call passes them — and sent a `type` field WindowInstance does not have. - HostEditor gated a block on authType === "warpgate", which is not one of the eight authType values. Unreachable, and it held only a label and a description. Removed. - FileManager passed onLoadDirectory to a sidebar that neither declares nor reads it, and FileManagerApp passed embedded to a FileManager that has no such prop. - TunnelApp's minimal Host was missing three required flags. The remainder were assertions at boundaries that are genuinely loose: bulk host import takes rows assembled from untyped input and validates them server-side, and a vi.fn() whose body only throws infers never. * feat: add drive file browser and drag-and-drop upload for RDP (#1187) Drive redirection could already be enabled per host, but the redirected drive lived inside guacd with no way to reach it from the browser: the client never handled onfilesystem, so the mounted volume was writable from Windows and invisible from Termix. Add a file browser panel that lists the drive, downloads files, and uploads them, plus drag-and-drop onto the display which opens the panel and uploads into the directory currently shown. The disable-upload and disable-download connection settings are honoured by the UI, not just passed to guacd. A rejected upload stops the BlobWriter without firing onerror or oncomplete, so the error ack is watched explicitly; otherwise the transfer would hang forever. Directory reads carry a deadline for the same reason. Also declares Guacamole.Object, Client.onfilesystem, BlobReader and BlobWriter in the local type definitions, which previously omitted them. * fix: keep the mouse working on touch-capable devices in RDP/VNC (#1190) Reported as "mouse input broken, keyboard fine" after 2.5.1 (#1102). 2.5.1 bound Guacamole.Mouse unconditionally. 2.6.0 replaced that with a three-way branch on touchMode, and the touch branches replace the mouse binding instead of adding to it: if (touchMode === "touchscreen") new Guacamole.Mouse.Touchscreen(el) else if (touchMode === "touchpad") new Guacamole.Mouse.Touchpad(el) else new Guacamole.Mouse(el) The two do not overlap. Guacamole.Mouse listens for mousedown/mousemove/ mouseup; Touchscreen and Touchpad listen only for touchstart/touchmove/ touchend. So in a touch mode nothing is listening for the mouse at all. touchMode defaults to "touchscreen" whenever navigator.maxTouchPoints > 0, which is true of every laptop with a touchscreen — machines that are still driven by a mouse. Those users lost the pointer entirely while the keyboard kept working, because Guacamole.Keyboard is bound independently. The physical pointer is now always bound and a touch emulator is layered on top when one is selected. Extracted to bindPointerInput() so the binding is testable; the test fails against the old branch. Note the issue also carries a second, unrelated report where well-formed mouse frames do reach guacd and the VNC leg ignores them. That one is not this, and the guacd image is pinned to 1.6.0 in both 2.5.1 and 2.6.1, so it is not an upgrade either. * fix: deduplicate /api/folders requests to prevent intermittent folder disappearance (#1191) * chore: sync Crowdin translations * fix: deduplicate /api/folders requests to prevent intermittent folder disappearance getSSHFolders() had no request deduplication while getSSHHosts() used a TTL cache with in-flight dedupe. When loadHosts() fired multiple times during rapid navigation between Credentials and Hosts panels, the folder response could arrive after the hosts response, causing the sidebar tree to render without folder metadata. - Add foldersCache (10s TTL) in hosts-request-cache.ts - Wrap getSSHFolders() API call in getCachedSSHFolders() - Invalidate folders cache on renameFolder, updateFolderMetadata, deleteAllHostsInFolder, and renameCredentialFolder - Include foldersCache in invalidateHostsAndStatusCaches() Closes Termix-SSH/Support#1103 Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> --------- Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * chore(deps): bump undici from 8.9.0 to 8.10.0 in the prod-minor-updates group (#1195) * chore: sync Crowdin translations * chore(deps): bump undici in the prod-minor-updates group Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.9.0 to 8.10.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: proxmox metrics integration * feat: add folder select to the host multi select feature * feat: implement context aware terminal toolbar with quick links, host info, image pasting, etc * feat: made toolbar open file manager at path * fix: delete folder route not invalidating host list cache * fix: match host list icons with tab bar iconfix * fix: change sidebar reset button icon to seperate against fullscreen button * feat: unify connection system and add connection logs to guacd hosts * fix: make mobile terminal scrollback match xterm wheel behavior (#1198) * fix: route mobile terminal scrolling through xterm viewport * docs: document mobile terminal touch scrolling * chore: add a note to not place files in docs * chore: remove touch imput from docs * feat: improve snippet system with variable snippets and collapse settings * feat: new fleet system with snippet, packages, files, and inventory features * fix: command pallete not loading new activity and made enter load first item * feat: add subhost from parent host organization feature * feat: add workspaces feature to save tab layout * perf: greatly improved performance across metrics polling and host management for enterprise users * feat: add a onboarding system with a new interface simplicity system * feat: finalize the multi dialect database system * fix: bind trusted MFA devices to client installs (#1202) * fix: merge OIDC group claims across sources (#1203) * fix: allow disabling SSH keepalives (#1204) * fix: distinguish reachable and available hosts (#1206) * fix: throttle session activity persistence (#1207) * fix: preserve saved RDP connection settings (#1208) * fix: authenticate tunnel status stream (#1209) * fix: select quick-created credentials (#1210) * fix: stagger initial metrics collection (#1211) * fix: stagger initial metrics collection * fix: admit reachable hosts to initial metrics * fix: prevent long host names shifting dashboard metrics (#1205) * feat: add global touch input settings (#1201) Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: keep host list row sizing stable (#1213) * fix(guacamole): correct Windows key mapping (#1216) * fix: normalize OIDC discovery issuer URLs (#1218) * fix: prompt for RDP domain credentials (#1212) * fix: route status checks by connection origin (#1214) * fix: restore desktop Tailscale configuration (#1215) * fix(docker): restore Node 24 for ssh2 native crypto (#1217) * feat: added new automations feature with events, channels, and steps * feat: allowed some tabs in the app rail to be opened as its own tab or in a new right sidebar * feat: expand onboarding process with more customization/features * feat: initial implementation of the termix ai feature * chore: run linter * fix: issue #424 (#424) https://github.com/Termix-SSH/Support/issues/424 * fix: Not working without internet connection. Missing OPKSSH binary in pre-built image. (#1133) https://github.com/Termix-SSH/Support/issues/1133 * fix: SQLite forceSave on telemetry writes causes periodic SSH terminal stalls in 2.6.x (#1109) https://github.com/Termix-SSH/Support/issues/1109 * feat: How to enable SSL using custom certificate (#1083) https://github.com/Termix-SSH/Support/issues/1083 * fix: show profile API key after creation (#1221) * feat: add trusted proxy authentication (#1222) * fix: clarify SSH agent authentication (#1224) * feat: add first-class split screen tabs (#1226) * feat: add split tab data model * feat: make split screens top-level tabs * feat: persist and manage split layouts * feat: launch native RDP on Windows desktop (#1223) * feat: launch native RDP on Windows * style: format native RDP launcher * feat: enhance custom disk and network metrics (#1220) * feat: enhance host disk and network metrics * fix: align enhanced metrics types * fix: preserve Proxmox guest identity on edit (#1219) * fix: preserve Proxmox guest identity on edit * fix: type Proxmox guest source metadata * chore: dead-code cleanup and small refactors (#1225) * chore: remove dead code and unused exports * chore: remove unused api client functions * chore: remove unused backend helpers * refactor: extract getErrorMessage helper for repeated error extraction * refactor: unify error message extraction across backend with getErrorMessage * refactor: unify error message extraction in frontend with getErrorMessage * refactor: merge duplicate imports from the same module * refactor: use Array.includes in TabBar * chore: drop biome, keep prettier as the single formatter * style: apply prettier formatting to refactored files * fix: close active tab with Ctrl+W on Windows * fix: make tray Quit terminate the desktop app * feat: verify host transfer integrity * fix: reuse transfer sessions during verification * feat: select the fastest host transfer route * feat: tune host transfers adaptively * feat: adapt background polling to activity (#1233) * feat: adapt background polling to activity * feat: extend adaptive polling coverage * feat: make polling cost and network aware (#1234) * feat: make repeat navigation feel instant (#1235) * feat: make file operations feel immediate (#1236) * feat: preload likely user actions (#1237) * feat: preload likely file previews * feat: preload likely host tools * feat: preload likely file viewers * fix: replace stale terminal input listeners * feat: add links to docs for all new features * chore: update readme * fix: warn before discarding host changes (#1229) * feat: learn local host action preferences (#1238) * feat(terminal-toolbar): add bounded movable desktop toolbar (#1239) * feat: add local adaptive decision engine (#1240) * feat: adapt speculative resource usage (#1241) * feat: persist adaptive transfer profiles (#1242) * Fix .preferred_username when using LDAP login. (#1243) * chore: sync Crowdin translations * Fix .preferred_username when using LDAP login. Strips internal LDAP prefix from username. --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * feat: learn direct transfer routes (#1244) * feat: learn speculative preload usefulness (#1245) * fix: - Adjusting the SSH Authentication from Vault to something else fails (#1152) https://github.com/Termix-SSH/Support/issues/1152 * fix: terminal graphical display, special characters inserted, distorted - `midnight comma... (#1145) https://github.com/Termix-SSH/Support/issues/1145 * feat: single click on host in list opens session - should be only on double click (#1146) https://github.com/Termix-SSH/Support/issues/1146 * feat: Terminal: custom font/ font selection/ how-to for adding a font - `MesloLGS NF` (#1140) https://github.com/Termix-SSH/Support/issues/1140 * fix: revert host single click to open session, make double click an option (#1146) Single click opens a session again by default. The old double click behavior can be turned on in Customize Sidebar. * chore: drop prettier check from beta release workflow, run formatter * chore: patch dependabot vulnerabilities via npm overrides * fix: reset adaptive resource state between tests to stop cross-test leaks * feat: replace terminal toolbar density popover with a native select * fix: pin hardwareConcurrency in adaptive budget tests so CI cores don't change the tier * fix: allow dylib files in mac universal arch rules so mas build packages sharp * feat: add file manager trash (#1250) * feat: add inheritable connection defaults (#1246) * feat: add desktop local terminal (#1247) * feat: add interactive terminal macros (#1248) * feat: add adaptive SSH local echo (#1249) * fix: sync desktop host changes immediately (#1252) * fix: route desktop sharing through synced server (#1253) * Fix terminal image uploads and add safe diagnostics (#1254) * feat: add configurable terminal image storage backends * feat: add admin image storage settings * fix: preserve native clipboard PNG uploads * fix: quote terminal image paths safely * docs: record image storage security remediation plan * fix: close remote image SFTP channels * fix: restrict remote image SFTP permissions * fix: bound remote image SFTP writes * fix: add best effort remote image retention * fix: cap normalized image output size * fix: bound concurrent image processing * fix: fail closed on local image inspection errors * test: cover fail closed image storage and atomic settings * fix: enforce remote image quota and upload admission * fix: serialize remote quota and verify existing paths * fix: use synchronous sqlite settings transaction * fix: keep settings transactions portable across dialects * fix: bound image processing admission queue * fix: serialize remote image quota across processes * fix: recover stale remote image locks safely * fix: preserve remote storage errors during unlock * fix: fail closed when stale lock removal fails * fix: harden image upload resource and storage cleanup * fix: bound SFTP operations and lock lifetime * fix: bound SFTP acquisition and cleanup callbacks * fix: close late SFTP channels and test cleanup stalls * fix: preserve SFTP inspection client context * feat: add image upload source metadata * fix: expose image upload metadata in logs * chore: exclude internal plan from pull request * style: apply prettier formatting --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: batch of security hardening fixes (#1255) * fix: bind desktop auto-session loopback check to the TCP peer address * fix: escape HTML entities in Vault OIDC callback responses * fix: route homepage ping and rss through the SSRF-safe outbound fetch * fix: scope tunnel status endpoints to hosts the caller can access * chore: update release notes * chore: update release notes to write more about the ai integration * fix: unbreak windows and macos electron builds after node-pty Install Spectre-mitigated MSVC libs on the Windows runner and cover node-pty's spawn-helper in the macOS universal arch rules. * fix: rework connection defaults ui into a dialog and add missing i18n keys * fix: rework macros panel with i18n, plain text matching, and list layout * feat: add docs links for trash, connection defaults, and local echo * fix: make image storage and trash tests pass on windows * fix: stop docs links squeezing sidebar panel headers * fix: put automations docs link back on the tabs row * fix(desktop): keep Linux credential storage working on unrecognised desktops (#1261) Chromium resolves safeStorage's backend from XDG_CURRENT_DESKTOP and falls back to the basic_text store for any desktop it has no mapping for, which covers every wlroots-style compositor (Hyprland, sway, niri, river). isEncryptionAvailable() reports false for that store, so saveRemoteSyncJwt refused every write and the OIDC sign-in it was storing appeared to succeed. The sync engine then found no JWT and reported the session as expired, which sent users looking at their OIDC provider for a fault that was never there. Name the libsecret backend explicitly on those desktops. They run an ordinary Secret Service, so that is enough to make encryption available again. KWallet desktops keep their auto-detected backend, an explicit --password-store still wins, and no stored secret can be orphaned by the switch because isEncryptionAvailable() gated every write that would have created one. Also stop discarding the {success: false} the main process returns when it cannot store a credential: on a machine with no Secret Service at all, the sign-in now says so instead of silently completing. Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com> * chore: update release notes * chore: update release notes * fix(file-manager): widen trash dialog so names and paths are not cut off * fix(sidebar): stop hover action tray overlapping the row below it * fix(hosts): make real status colors toggle actually apply * feat(local-terminal): add rail button and fix hardcoded tab label * chore: update release notes * fix(ai): hide assistant everywhere when admin disables it globally * fix(automations): fix concurrency race, wire docker and internal event triggers Claim the in-flight slot in the same tick it is checked, poll container state for docker_event triggers, emit the internal events, apply the schedule time zone, and expose the concurrency policy in the editor. * fix(sidebar): rework host and credential drag-to-reorder Adds a lock toggle in the sort menu and fixes reorder positioning, cross-folder drops, and the duplicate drop indicator. * chore(sidebar): drop unused sortKey prop from host and credential trees * fix(sidebar): fix row height in click tray mode so status stripes stop overlapping * fix(onboarding): remove add-first-host step that closed onboarding mid-flow * fix(release): upload release notes so Mac App Store review submission stops failing * chore: sync Crowdin translations for 2.7.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: T3rM1nAt0-R <niraj.sangani91@gmail.com> Co-authored-by: Horziox <horziox.dev@gmail.com> Co-authored-by: William Shi <184219650@qq.com> Co-authored-by: Carl <scarlettme@qq.com> Co-authored-by: Raw_Nuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: njz-cvm <njz@cvm.com> Co-authored-by: Alexandre VARGAS <alexandre.vargas.lopez@gmail.com> Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com>
This commit is contained in:
co-authored by
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ZacharyZcR
kacperpietrzyk
Brennan Neoh
Claude Sonnet 5
T3rM1nAt0-R
Horziox
William Shi
Carl
Raw_Nuke
njz-cvm
Alexandre VARGAS
alexandre-vl
parent
5021ccf3e2
commit
7ae1648c25
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isReadOnlyCommand } from "../../ai/tools/command-allowlist.js";
|
||||
|
||||
/**
|
||||
* These commands can run without a per-command approval click, so the parser
|
||||
* has to refuse anything that could become a second command. Substring
|
||||
* matching would pass "df; rm -rf /", which is the whole reason this is
|
||||
* argv-based.
|
||||
*/
|
||||
describe("isReadOnlyCommand", () => {
|
||||
it("allows plain diagnostics", () => {
|
||||
for (const command of [
|
||||
"df -h",
|
||||
"uptime",
|
||||
"free -m",
|
||||
"whoami",
|
||||
"ps aux",
|
||||
"lsblk",
|
||||
"uname -a",
|
||||
"/usr/bin/df -h",
|
||||
]) {
|
||||
expect(isReadOnlyCommand(command).allowed, command).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects command chaining and redirection", () => {
|
||||
for (const command of [
|
||||
"df; rm -rf /",
|
||||
"df && curl evil.example",
|
||||
"df || reboot",
|
||||
"df | sh",
|
||||
"df > /etc/passwd",
|
||||
"df >> /etc/passwd",
|
||||
"cat < /etc/shadow",
|
||||
"echo `whoami`",
|
||||
"echo $(id)",
|
||||
"df\nrm -rf /",
|
||||
"df \\\n rm",
|
||||
]) {
|
||||
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects privilege escalation", () => {
|
||||
for (const command of ["sudo df -h", "su root", "doas df", "env df"]) {
|
||||
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects commands that are not on the list", () => {
|
||||
for (const command of ["rm -rf /", "curl evil.example", "vi /etc/passwd"]) {
|
||||
expect(isReadOnlyCommand(command).allowed, command).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("limits systemctl and docker to read-only subcommands", () => {
|
||||
expect(isReadOnlyCommand("systemctl status nginx").allowed).toBe(true);
|
||||
expect(isReadOnlyCommand("systemctl restart nginx").allowed).toBe(false);
|
||||
expect(isReadOnlyCommand("systemctl stop nginx").allowed).toBe(false);
|
||||
|
||||
expect(isReadOnlyCommand("docker ps").allowed).toBe(true);
|
||||
expect(isReadOnlyCommand("docker stats --no-stream").allowed).toBe(true);
|
||||
expect(isReadOnlyCommand("docker rm -f web").allowed).toBe(false);
|
||||
expect(isReadOnlyCommand("docker exec -it web sh").allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("limits cat to safe paths", () => {
|
||||
expect(isReadOnlyCommand("cat /proc/meminfo").allowed).toBe(true);
|
||||
expect(isReadOnlyCommand("cat /etc/os-release").allowed).toBe(true);
|
||||
expect(isReadOnlyCommand("cat /etc/shadow").allowed).toBe(false);
|
||||
expect(isReadOnlyCommand("cat ~/.ssh/id_rsa").allowed).toBe(false);
|
||||
expect(isReadOnlyCommand("cat").allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty command", () => {
|
||||
expect(isReadOnlyCommand("").allowed).toBe(false);
|
||||
expect(isReadOnlyCommand(" ").allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSystemPrompt } from "../../ai/context.js";
|
||||
|
||||
const BASE = { hostCount: 3, allowReadOnlyCommands: false };
|
||||
|
||||
describe("buildSystemPrompt", () => {
|
||||
it("tells the assistant to stay inside what was asked", () => {
|
||||
// Without these the assistant answered "what is running on this server"
|
||||
// by also proposing a monitoring script and an alert rule nobody wanted.
|
||||
const prompt = buildSystemPrompt(BASE);
|
||||
|
||||
expect(prompt).toContain("nothing beyond it");
|
||||
expect(prompt).toMatch(/Read, then report\. Do not propose anything\./);
|
||||
expect(prompt).toMatch(/no monitoring, no alert rules, no scripts/);
|
||||
expect(prompt).toMatch(/One request means one proposal at most/);
|
||||
});
|
||||
|
||||
it("states that it proposes rather than applies", () => {
|
||||
const prompt = buildSystemPrompt(BASE);
|
||||
expect(prompt).toContain("cannot change anything directly");
|
||||
expect(prompt).toContain("Never claim you have done something");
|
||||
});
|
||||
|
||||
it("never claims credential access", () => {
|
||||
const prompt = buildSystemPrompt(BASE);
|
||||
expect(prompt).toMatch(/no access to passwords, SSH keys, API keys/);
|
||||
});
|
||||
|
||||
it("mentions read-only commands only when the user opted in", () => {
|
||||
expect(buildSystemPrompt(BASE)).not.toMatch(/read-only diagnostic/);
|
||||
expect(buildSystemPrompt({ ...BASE, allowReadOnlyCommands: true })).toMatch(
|
||||
/read-only diagnostic/,
|
||||
);
|
||||
});
|
||||
|
||||
it("pluralises the host count", () => {
|
||||
expect(buildSystemPrompt({ ...BASE, hostCount: 1 })).toContain("1 host ");
|
||||
expect(buildSystemPrompt({ ...BASE, hostCount: 2 })).toContain("2 hosts");
|
||||
});
|
||||
|
||||
it("includes the active tab only when there is one", () => {
|
||||
expect(buildSystemPrompt(BASE)).not.toContain("currently looking at");
|
||||
expect(buildSystemPrompt({ ...BASE, activeTab: "terminal" })).toContain(
|
||||
"currently looking at: terminal",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_PRIVATE_ALLOWLIST,
|
||||
evaluateEgress,
|
||||
isPrivateDestination,
|
||||
parseAllowlist,
|
||||
} from "../../ai/egress.js";
|
||||
|
||||
/**
|
||||
* The rule that lets a self-hosted Ollama work without turning the backend
|
||||
* into an authenticated probe of its own network: private destinations are
|
||||
* refused unless an admin named the host.
|
||||
*/
|
||||
describe("isPrivateDestination", () => {
|
||||
it("recognises loopback and private ranges", () => {
|
||||
for (const url of [
|
||||
"http://localhost:11434",
|
||||
"http://127.0.0.1:11434",
|
||||
"http://10.0.0.5:11434",
|
||||
"http://192.168.1.10:11434",
|
||||
"http://172.16.4.4:11434",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
"http://[::1]:11434",
|
||||
]) {
|
||||
expect(isPrivateDestination(url), url).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats public hosts as public", () => {
|
||||
for (const url of [
|
||||
"https://api.openai.com/v1",
|
||||
"https://api.anthropic.com",
|
||||
"https://generativelanguage.googleapis.com",
|
||||
"http://8.8.8.8",
|
||||
]) {
|
||||
expect(isPrivateDestination(url), url).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateEgress", () => {
|
||||
it("allows public destinations without an allowlist entry", () => {
|
||||
const decision = evaluateEgress("https://api.openai.com/v1", []);
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.isPrivate).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a private destination that is not allowlisted", () => {
|
||||
const decision = evaluateEgress("http://192.168.1.50:11434", ["localhost"]);
|
||||
expect(decision.allowed).toBe(false);
|
||||
expect(decision.isPrivate).toBe(true);
|
||||
expect(decision.reason).toContain("allowlist");
|
||||
});
|
||||
|
||||
it("allows a private destination once its host is allowlisted", () => {
|
||||
const decision = evaluateEgress("http://localhost:11434", [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
]);
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.isPrivate).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the allowlist case-insensitively", () => {
|
||||
expect(
|
||||
evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let one allowlisted host authorise another", () => {
|
||||
// A cloud metadata endpoint is the classic target, so an allowlist for
|
||||
// localhost must not open 169.254.169.254.
|
||||
const decision = evaluateEgress("http://169.254.169.254/latest/meta-data", [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
]);
|
||||
expect(decision.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses non-http protocols and embedded credentials", () => {
|
||||
expect(evaluateEgress("file:///etc/passwd", []).allowed).toBe(false);
|
||||
expect(evaluateEgress("ftp://example.com", []).allowed).toBe(false);
|
||||
expect(evaluateEgress("https://user:pass@api.openai.com", []).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a malformed url", () => {
|
||||
expect(evaluateEgress("not a url", []).allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAllowlist", () => {
|
||||
it("falls back to the defaults when unset or malformed", () => {
|
||||
expect(parseAllowlist(null)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||
expect(parseAllowlist("not json")).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||
expect(parseAllowlist('{"a":1}')).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||
});
|
||||
|
||||
it("normalises stored entries", () => {
|
||||
expect(parseAllowlist('[" LocalHost ", "", "10.0.0.5"]')).toEqual([
|
||||
"localhost",
|
||||
"10.0.0.5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("honours a deliberately empty allowlist", () => {
|
||||
// An admin clearing the list must actually block everything private,
|
||||
// not silently get the defaults back.
|
||||
expect(parseAllowlist("[]")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatChunk } from "../../ai/providers/types.js";
|
||||
|
||||
const streamChat = vi.fn();
|
||||
const handler = vi.fn();
|
||||
|
||||
vi.mock("../../ai/providers/registry.js", () => ({
|
||||
getAdapter: () => ({ streamChat, listModels: async () => [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../ai/tools/catalog.js", () => ({
|
||||
getTool: (name: string) =>
|
||||
name === "list_hosts"
|
||||
? {
|
||||
name: "list_hosts",
|
||||
description: "List hosts",
|
||||
category: "read",
|
||||
parameters: { type: "object", properties: {} },
|
||||
handler,
|
||||
}
|
||||
: undefined,
|
||||
toolDefinitions: () => [
|
||||
{ name: "list_hosts", description: "List hosts", parameters: {} },
|
||||
],
|
||||
}));
|
||||
|
||||
const { runAgent } = await import("../../ai/engine.js");
|
||||
|
||||
function chunks(...values: ChatChunk[]) {
|
||||
return (async function* () {
|
||||
for (const value of values) yield value;
|
||||
})();
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
config: { providerType: "ollama" as const },
|
||||
model: "test",
|
||||
system: "system",
|
||||
context: {
|
||||
userId: "user-1",
|
||||
conversationId: 1,
|
||||
allowReadOnlyCommands: false,
|
||||
},
|
||||
};
|
||||
|
||||
async function collect(history: any[] = []) {
|
||||
const events: any[] = [];
|
||||
for await (const event of runAgent({ ...BASE, history })) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe("runAgent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("streams text and finishes when no tools are called", async () => {
|
||||
streamChat.mockReturnValueOnce(
|
||||
chunks({ type: "text", text: "hello" }, { type: "done" }),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
expect(events.filter((e) => e.type === "token")).toHaveLength(1);
|
||||
expect(events.at(-1).type).toBe("done");
|
||||
});
|
||||
|
||||
it("runs a known tool and feeds the result back", async () => {
|
||||
handler.mockResolvedValue({ hosts: [{ id: 1, name: "web-1" }] });
|
||||
streamChat
|
||||
.mockReturnValueOnce(
|
||||
chunks(
|
||||
{
|
||||
type: "tool_call",
|
||||
call: { id: "c1", name: "list_hosts", arguments: {} },
|
||||
},
|
||||
{ type: "done" },
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
chunks({ type: "text", text: "ok" }, { type: "done" }),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(events.some((e) => e.type === "tool_result")).toBe(true);
|
||||
expect(streamChat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refuses a tool that is not in the catalog", async () => {
|
||||
streamChat
|
||||
.mockReturnValueOnce(
|
||||
chunks(
|
||||
{
|
||||
type: "tool_call",
|
||||
call: { id: "c1", name: "read_credentials", arguments: {} },
|
||||
},
|
||||
{ type: "done" },
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
chunks({ type: "text", text: "ok" }, { type: "done" }),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
// A model can emit any name it likes; only the catalog decides what runs.
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
const result = events.find((e) => e.type === "tool_result");
|
||||
expect(JSON.stringify(result.result)).toContain("Unknown tool");
|
||||
});
|
||||
|
||||
it("surfaces a handler failure without ending the run", async () => {
|
||||
handler.mockRejectedValue(new Error("database is down"));
|
||||
streamChat
|
||||
.mockReturnValueOnce(
|
||||
chunks(
|
||||
{
|
||||
type: "tool_call",
|
||||
call: { id: "c1", name: "list_hosts", arguments: {} },
|
||||
},
|
||||
{ type: "done" },
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
chunks({ type: "text", text: "ok" }, { type: "done" }),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
const result = events.find((e) => e.type === "tool_result");
|
||||
expect(JSON.stringify(result.result)).toContain("database is down");
|
||||
expect(events.at(-1).type).toBe("done");
|
||||
});
|
||||
|
||||
it("closes the tool call when a tool returns a proposal", async () => {
|
||||
// Without a matching tool_result the call rendered as permanently
|
||||
// running, even though the work was done and awaiting the user.
|
||||
handler.mockResolvedValue({
|
||||
__proposal: true,
|
||||
kind: "propose_create_host",
|
||||
summary: "Add host web-1",
|
||||
payload: {},
|
||||
});
|
||||
streamChat
|
||||
.mockReturnValueOnce(
|
||||
chunks(
|
||||
{
|
||||
type: "tool_call",
|
||||
call: { id: "c1", name: "list_hosts", arguments: {} },
|
||||
},
|
||||
{ type: "done" },
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
chunks({ type: "text", text: "ok" }, { type: "done" }),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
const callIndex = events.findIndex((e) => e.type === "tool_call");
|
||||
const resultIndex = events.findIndex((e) => e.type === "tool_result");
|
||||
const proposalIndex = events.findIndex((e) => e.type === "proposal");
|
||||
|
||||
expect(resultIndex).toBeGreaterThan(callIndex);
|
||||
expect(proposalIndex).toBeGreaterThan(resultIndex);
|
||||
expect(events[resultIndex]).toMatchObject({
|
||||
name: "list_hosts",
|
||||
result: { status: "awaiting_user_approval" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a provider failure as an error and stops", async () => {
|
||||
streamChat.mockImplementationOnce(() => {
|
||||
throw new Error("provider unreachable");
|
||||
});
|
||||
|
||||
const events = await collect();
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "error",
|
||||
message: "provider unreachable",
|
||||
});
|
||||
});
|
||||
|
||||
it("stops after too many tool turns", async () => {
|
||||
handler.mockResolvedValue({ ok: true });
|
||||
streamChat.mockImplementation(() =>
|
||||
chunks(
|
||||
{
|
||||
type: "tool_call",
|
||||
call: { id: "c", name: "list_hosts", arguments: {} },
|
||||
},
|
||||
{ type: "done" },
|
||||
),
|
||||
);
|
||||
|
||||
const events = await collect();
|
||||
|
||||
// A model that never stops calling tools must not spin forever.
|
||||
expect(events.at(-1)).toMatchObject({ type: "error" });
|
||||
expect(streamChat.mock.calls.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const settingsRepository = { getBoolean: vi.fn() };
|
||||
const userPreferenceRepository = { findByUserId: vi.fn() };
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => settingsRepository,
|
||||
createCurrentUserPreferenceRepository: () => userPreferenceRepository,
|
||||
}));
|
||||
|
||||
const { isAiGloballyEnabled, resolveAiAccess } =
|
||||
await import("../../ai/gating.js");
|
||||
|
||||
describe("AI gating", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("defaults to off so upgrading an install enables nothing", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(false);
|
||||
await isAiGloballyEnabled();
|
||||
expect(settingsRepository.getBoolean).toHaveBeenCalledWith(
|
||||
"ai_globally_enabled",
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks everyone when the admin global is off", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(false);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: true,
|
||||
aiReadOnlyCommands: true,
|
||||
});
|
||||
|
||||
const access = await resolveAiAccess("user-1");
|
||||
|
||||
expect(access.enabled).toBe(false);
|
||||
expect(access.allowReadOnlyCommands).toBe(false);
|
||||
// The kill switch short-circuits, so the preference is never consulted.
|
||||
expect(userPreferenceRepository.findByUserId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks a user who has not enabled it", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: false,
|
||||
});
|
||||
|
||||
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("treats never-asked as not enabled", async () => {
|
||||
// Null means the user was never shown the choice, which is not consent.
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: null,
|
||||
});
|
||||
|
||||
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a missing preference row as not enabled", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue(null);
|
||||
|
||||
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("allows only when both gates are open", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: true,
|
||||
aiReadOnlyCommands: true,
|
||||
});
|
||||
|
||||
const access = await resolveAiAccess("user-1");
|
||||
|
||||
expect(access.enabled).toBe(true);
|
||||
expect(access.allowReadOnlyCommands).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps read-only commands off unless separately opted in", async () => {
|
||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
||||
aiAssistantEnabled: true,
|
||||
aiReadOnlyCommands: null,
|
||||
});
|
||||
|
||||
const access = await resolveAiAccess("user-1");
|
||||
|
||||
expect(access.enabled).toBe(true);
|
||||
expect(access.allowReadOnlyCommands).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hostRepository = {
|
||||
create: vi.fn(),
|
||||
findByIdForUser: vi.fn(),
|
||||
updateForUser: vi.fn(),
|
||||
deleteForUser: vi.fn(),
|
||||
};
|
||||
const snippetRepository = {
|
||||
createSnippet: vi.fn(),
|
||||
findOwnedById: vi.fn(),
|
||||
updateSnippet: vi.fn(),
|
||||
deleteSnippet: vi.fn(),
|
||||
};
|
||||
const fleetRepository = { create: vi.fn(), addMember: vi.fn() };
|
||||
const alertRepository = { createAlertRule: vi.fn() };
|
||||
const automationRepository = { create: vi.fn() };
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => hostRepository,
|
||||
createCurrentSnippetRepository: () => snippetRepository,
|
||||
createCurrentFleetRepository: () => fleetRepository,
|
||||
createCurrentAlertRepository: () => alertRepository,
|
||||
createCurrentAutomationRepository: () => automationRepository,
|
||||
}));
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||
}));
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
vi.mock("../../hosts/ssh-client-factory.js", () => ({
|
||||
createFleetSshFactory: () => () => ({}),
|
||||
getFleetPoolKey: () => "pool",
|
||||
}));
|
||||
vi.mock("../../hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
_key: string,
|
||||
_factory: unknown,
|
||||
run: (client: unknown) => Promise<unknown>,
|
||||
) => run({}),
|
||||
}));
|
||||
|
||||
const { applyProposal } = await import("../../ai/tools/executor.js");
|
||||
|
||||
describe("applyProposal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("refuses a kind that is not a real tool", async () => {
|
||||
// The stored payload is treated as untrusted even though the server wrote
|
||||
// it, because a proposal can outlive the release that created it.
|
||||
await expect(
|
||||
applyProposal("propose_delete_everything", {}, "user-1"),
|
||||
).rejects.toThrow("Unknown proposal kind");
|
||||
});
|
||||
|
||||
it("validates an automation definition before creating it", async () => {
|
||||
// Reuses the automations route's own validator, so a definition the model
|
||||
// invented is held to the same standard as a hand-written one.
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_create_automation",
|
||||
{
|
||||
name: "bad",
|
||||
definition: { trigger: { kind: "nonsense" }, steps: [] },
|
||||
},
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(automationRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a valid automation disabled so it cannot fire unwatched", async () => {
|
||||
automationRepository.create.mockResolvedValue({ id: 5, name: "nightly" });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_create_automation",
|
||||
{
|
||||
name: "nightly",
|
||||
definition: {
|
||||
trigger: { kind: "schedule", intervalSeconds: 3600 },
|
||||
steps: [{ id: "s1", type: "wait", seconds: 1 }],
|
||||
},
|
||||
},
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(automationRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs an approved command only on a host the user can reach", async () => {
|
||||
// resolveHostById returns null when the connect-level permission check
|
||||
// fails, so an unreachable host never gets as far as an SSH attempt.
|
||||
resolveHostById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "uptime" },
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow("Host not found");
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns command output on success", async () => {
|
||||
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
|
||||
execCommand.mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "uptime" },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.summary).toContain("up 3 days");
|
||||
});
|
||||
|
||||
it("resolves the host rather than using a raw repository row", async () => {
|
||||
// A raw row has no decrypted auth and an unresolved jumpHosts field, which
|
||||
// made the SSH factory fail with a jump host error on hosts that have none.
|
||||
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
|
||||
execCommand.mockResolvedValue({ stdout: "ok", stderr: "", code: 0 });
|
||||
|
||||
await applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "uptime" },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(resolveHostById).toHaveBeenCalledWith(9, "user-1");
|
||||
expect(hostRepository.findByIdForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a non-zero exit rather than reporting success", async () => {
|
||||
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
|
||||
execCommand.mockResolvedValue({ stdout: "", stderr: "denied", code: 1 });
|
||||
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_run_command",
|
||||
{ hostId: 9, command: "cat /etc/shadow" },
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow("code 1");
|
||||
});
|
||||
|
||||
it("creates a host through the normal repository", async () => {
|
||||
hostRepository.create.mockResolvedValue({ id: 7, name: "web-1" });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_create_host",
|
||||
{ name: "web-1", ip: "10.0.0.5", port: 22, tags: ["prod"] },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(hostRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
name: "web-1",
|
||||
ip: "10.0.0.5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a host payload missing required fields", async () => {
|
||||
await expect(
|
||||
applyProposal("propose_create_host", { name: "web-1" }, "user-1"),
|
||||
).rejects.toThrow("ip is required");
|
||||
expect(hostRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes an update to the approving user", async () => {
|
||||
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
|
||||
hostRepository.updateForUser.mockResolvedValue({ id: 7 });
|
||||
|
||||
await applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: 7, changes: { name: "renamed" } },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(hostRepository.findByIdForUser).toHaveBeenCalledWith("user-1", 7);
|
||||
expect(hostRepository.updateForUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
7,
|
||||
expect.objectContaining({ name: "renamed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to update a host the user does not own", async () => {
|
||||
hostRepository.findByIdForUser.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: 999, changes: { name: "x" } },
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow("Host not found");
|
||||
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-numeric id rather than coercing it", async () => {
|
||||
await expect(
|
||||
applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: "7; DROP TABLE hosts", changes: { name: "x" } },
|
||||
"user-1",
|
||||
),
|
||||
).rejects.toThrow("hostId must be a positive integer");
|
||||
});
|
||||
|
||||
it("only adds fleet members the approving user owns", async () => {
|
||||
fleetRepository.create.mockResolvedValue({ id: 3, name: "prod" });
|
||||
hostRepository.findByIdForUser.mockImplementation(
|
||||
async (_userId: string, hostId: number) =>
|
||||
hostId === 1 ? { id: 1 } : null,
|
||||
);
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_create_fleet",
|
||||
{ name: "prod", hostIds: [1, 2] },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(fleetRepository.addMember).toHaveBeenCalledTimes(1);
|
||||
expect(fleetRepository.addMember).toHaveBeenCalledWith(3, 1);
|
||||
expect(result.summary).toContain("1 host");
|
||||
});
|
||||
|
||||
it("reports nothing to change on an empty update", async () => {
|
||||
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
|
||||
|
||||
const result = await applyProposal(
|
||||
"propose_update_host",
|
||||
{ hostId: 7, changes: {} },
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatChunk } from "../../ai/providers/types.js";
|
||||
|
||||
const providerFetch = vi.fn();
|
||||
|
||||
vi.mock("../../ai/providers/http.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../ai/providers/http.js")
|
||||
>("../../ai/providers/http.js");
|
||||
return { ...actual, providerFetch };
|
||||
});
|
||||
|
||||
const { openAiAdapter } = await import("../../ai/providers/openai.js");
|
||||
const { ollamaAdapter } = await import("../../ai/providers/ollama.js");
|
||||
const { geminiAdapter } = await import("../../ai/providers/gemini.js");
|
||||
|
||||
/** Builds a Response whose body streams the given text chunks. */
|
||||
function streamingResponse(lines: string[]): Response {
|
||||
const encoder = new TextEncoder();
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
getReader() {
|
||||
let index = 0;
|
||||
return {
|
||||
async read() {
|
||||
if (index >= lines.length) return { done: true, value: undefined };
|
||||
return { done: false, value: encoder.encode(lines[index++]) };
|
||||
},
|
||||
releaseLock() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** One SSE frame, built from an object so the JSON stays readable. */
|
||||
function sseFrame(payload: unknown): string {
|
||||
return `data: ${JSON.stringify(payload)}\n`;
|
||||
}
|
||||
|
||||
async function collect(iterable: AsyncIterable<ChatChunk>) {
|
||||
const chunks: ChatChunk[] = [];
|
||||
for await (const chunk of iterable) chunks.push(chunk);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const REQUEST = {
|
||||
model: "test-model",
|
||||
system: "system",
|
||||
messages: [{ role: "user" as const, content: "hi" }],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
describe("openAiAdapter", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("normalises streamed text", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
'data: {"choices":[{"delta":{"content":"Hel"}}]}\n',
|
||||
'data: {"choices":[{"delta":{"content":"lo"}}]}\n',
|
||||
"data: [DONE]\n",
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
);
|
||||
|
||||
expect(chunks.filter((c) => c.type === "text")).toEqual([
|
||||
{ type: "text", text: "Hel" },
|
||||
{ type: "text", text: "lo" },
|
||||
]);
|
||||
expect(chunks.at(-1)?.type).toBe("done");
|
||||
});
|
||||
|
||||
it("reassembles tool arguments split across deltas", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"list_hosts","arguments":"{\\"a"}}]}}]}\n',
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\":1}"}}]}}]}\n',
|
||||
"data: [DONE]\n",
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
);
|
||||
|
||||
const call = chunks.find((c) => c.type === "tool_call");
|
||||
expect(call).toMatchObject({
|
||||
type: "tool_call",
|
||||
call: { id: "c1", name: "list_hosts", arguments: { a: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("survives malformed tool arguments", async () => {
|
||||
// A model that emits broken JSON gets an empty object; the tool's own
|
||||
// validation then reports the problem back to it.
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"list_hosts","arguments":"{not json"}}]}}]}\n',
|
||||
"data: [DONE]\n",
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
);
|
||||
|
||||
expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({
|
||||
call: { arguments: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("needs a base url for an openai-compatible provider", async () => {
|
||||
await expect(
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ providerType: "openai_compatible" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("base URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ollamaAdapter", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("reads newline-delimited json rather than sse", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
'{"message":{"content":"Hel"}}\n',
|
||||
'{"message":{"content":"lo"}}\n',
|
||||
'{"done":true,"done_reason":"stop"}\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
|
||||
);
|
||||
|
||||
expect(chunks.filter((c) => c.type === "text")).toHaveLength(2);
|
||||
expect(chunks.at(-1)).toMatchObject({ type: "done", stopReason: "stop" });
|
||||
});
|
||||
|
||||
it("accepts tool arguments as an object or a json string", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
'{"message":{"tool_calls":[{"function":{"name":"list_hosts","arguments":{"a":1}}}]}}\n',
|
||||
'{"message":{"tool_calls":[{"function":{"name":"get_host","arguments":"{\\"hostId\\":2}"}}]}}\n',
|
||||
'{"done":true}\n',
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
|
||||
);
|
||||
|
||||
const calls = chunks.filter((c) => c.type === "tool_call") as any[];
|
||||
expect(calls[0].call.arguments).toEqual({ a: 1 });
|
||||
expect(calls[1].call.arguments).toEqual({ hostId: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("geminiAdapter", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("carries thoughtSignature off a function call", async () => {
|
||||
// Gemini 2.5+ 400s a follow-up whose functionCall parts lost their
|
||||
// signature, which broke every conversation on the second turn.
|
||||
providerFetch.mockResolvedValue(
|
||||
streamingResponse([
|
||||
sseFrame({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: { name: "list_hosts", args: {} },
|
||||
thoughtSignature: "sig-abc",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
geminiAdapter.streamChat(
|
||||
{ providerType: "gemini", apiKey: "k" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({
|
||||
call: { name: "list_hosts", providerSignature: "sig-abc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("echoes the signature back on the next turn", async () => {
|
||||
providerFetch.mockResolvedValue(streamingResponse([]));
|
||||
|
||||
await collect(
|
||||
geminiAdapter.streamChat(
|
||||
{ providerType: "gemini", apiKey: "k" },
|
||||
{
|
||||
...REQUEST,
|
||||
messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "c1",
|
||||
name: "list_hosts",
|
||||
arguments: {},
|
||||
providerSignature: "sig-abc",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: "{}",
|
||||
toolCallId: "c1",
|
||||
toolName: "list_hosts",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const body = JSON.parse(providerFetch.mock.calls[0][1].body as string);
|
||||
const modelTurn = body.contents.find((c: any) => c.role === "model");
|
||||
expect(modelTurn.parts[0].thoughtSignature).toBe("sig-abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertOk error messages", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function errorResponse(status: number, body: string): Response {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
text: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
it("pulls the message out of a nested error body", async () => {
|
||||
// Slicing the raw JSON used to cut the text off mid-sentence.
|
||||
providerFetch.mockResolvedValue(
|
||||
errorResponse(
|
||||
400,
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 400,
|
||||
message: "Function call is missing a signature.",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
).rejects.toThrow("Function call is missing a signature.");
|
||||
});
|
||||
|
||||
it("explains a rate limit instead of dumping the body", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
errorResponse(
|
||||
429,
|
||||
JSON.stringify({ error: { message: "You exceeded your quota." } }),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
).rejects.toThrow(/rate limit reached/i);
|
||||
});
|
||||
|
||||
it("explains a rejected key", async () => {
|
||||
providerFetch.mockResolvedValue(
|
||||
errorResponse(401, JSON.stringify({ error: { message: "Bad key" } })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
).rejects.toThrow(/rejected the API key/i);
|
||||
});
|
||||
|
||||
it("falls back to a trimmed snippet for a non-JSON body", async () => {
|
||||
providerFetch.mockResolvedValue(errorResponse(500, "upstream exploded"));
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
).rejects.toThrow("upstream exploded");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { REDACTED, redact, redactString } from "../../ai/redaction.js";
|
||||
|
||||
describe("redact", () => {
|
||||
it("drops secret-named fields at any depth", () => {
|
||||
const input = {
|
||||
name: "web-1",
|
||||
password: "hunter2",
|
||||
nested: { privateKey: "abc", apiKey: "def", port: 22 },
|
||||
list: [{ keyPassword: "xyz", label: "ok" }],
|
||||
};
|
||||
|
||||
const output = redact(input) as any;
|
||||
|
||||
expect(output.name).toBe("web-1");
|
||||
expect(output.password).toBe(REDACTED);
|
||||
expect(output.nested.privateKey).toBe(REDACTED);
|
||||
expect(output.nested.apiKey).toBe(REDACTED);
|
||||
expect(output.nested.port).toBe(22);
|
||||
expect(output.list[0].keyPassword).toBe(REDACTED);
|
||||
expect(output.list[0].label).toBe("ok");
|
||||
});
|
||||
|
||||
it("keeps a null secret null so absence stays distinguishable", () => {
|
||||
const output = redact({ password: null }) as any;
|
||||
expect(output.password).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves ordinary values untouched", () => {
|
||||
const input = { id: 4, enabled: true, tags: ["a", "b"], note: null };
|
||||
expect(redact(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("does not recurse forever on a cyclic object", () => {
|
||||
const cyclic: Record<string, unknown> = { name: "loop" };
|
||||
cyclic.self = cyclic;
|
||||
expect(() => redact(cyclic)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactString", () => {
|
||||
it("masks private key blocks", () => {
|
||||
const text =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----";
|
||||
expect(redactString(text)).toBe("[redacted private key]");
|
||||
});
|
||||
|
||||
it("masks provider api keys", () => {
|
||||
expect(redactString("key is sk-abcdefghijklmnopqrst here")).toContain(
|
||||
"[redacted api key]",
|
||||
);
|
||||
expect(redactString("key is sk-ant-abcdefghijklmnopqrst here")).toContain(
|
||||
"[redacted api key]",
|
||||
);
|
||||
});
|
||||
|
||||
it("masks bearer tokens and jwts", () => {
|
||||
expect(
|
||||
redactString("Authorization: Bearer abcdefghijklmnopqrst"),
|
||||
).toContain("Bearer [redacted]");
|
||||
expect(
|
||||
redactString("token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefgh"),
|
||||
).toContain("[redacted token]");
|
||||
});
|
||||
|
||||
it("masks Termix api keys", () => {
|
||||
expect(redactString("tmx_abcdefghijklmnopqrstuvwx")).toContain(
|
||||
"[redacted token]",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves ordinary prose alone", () => {
|
||||
const text = "The disk on web-1 is 82 percent full.";
|
||||
expect(redactString(text)).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AI_TOOLS,
|
||||
FORBIDDEN_DOMAINS,
|
||||
getTool,
|
||||
listToolNames,
|
||||
toolDefinitions,
|
||||
} from "../../ai/tools/catalog.js";
|
||||
|
||||
/**
|
||||
* The security regression test for the whole feature.
|
||||
*
|
||||
* PermissionManager.requirePermission is defined but mounted on zero routes,
|
||||
* so RBAC strings do not gate anything at the route layer. "The assistant
|
||||
* cannot reach credentials or user administration" is therefore a property of
|
||||
* this catalog, and nothing else. If a future change adds a tool that touches a
|
||||
* forbidden domain, this test is what catches it.
|
||||
*/
|
||||
describe("AI tool catalog", () => {
|
||||
it("exposes no tool naming a forbidden domain", () => {
|
||||
for (const tool of AI_TOOLS) {
|
||||
for (const domain of FORBIDDEN_DOMAINS) {
|
||||
expect(
|
||||
tool.name.includes(domain),
|
||||
`${tool.name} references the forbidden domain "${domain}"`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has no tool that could read a credential", () => {
|
||||
const banned = [
|
||||
"get_credential",
|
||||
"list_credentials",
|
||||
"get_password",
|
||||
"get_private_key",
|
||||
"get_api_key",
|
||||
"create_user",
|
||||
"delete_user",
|
||||
"grant_permission",
|
||||
"update_settings",
|
||||
];
|
||||
for (const name of banned) {
|
||||
expect(getTool(name), `${name} must not exist`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("only allows read or propose categories", () => {
|
||||
for (const tool of AI_TOOLS) {
|
||||
expect(["read", "propose"]).toContain(tool.category);
|
||||
}
|
||||
});
|
||||
|
||||
it("names every tool by its category", () => {
|
||||
// A propose tool that does not say "propose" would read as a direct action
|
||||
// in the transcript, which is exactly the confusion this feature avoids.
|
||||
for (const tool of AI_TOOLS) {
|
||||
if (tool.category === "propose") {
|
||||
expect(
|
||||
tool.name.startsWith("propose_"),
|
||||
`${tool.name} is a propose tool but is not named propose_*`,
|
||||
).toBe(true);
|
||||
} else {
|
||||
expect(
|
||||
tool.name.startsWith("propose_"),
|
||||
`${tool.name} is a read tool but is named propose_*`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has unique tool names", () => {
|
||||
const names = listToolNames();
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it("gives every tool a described object schema", () => {
|
||||
for (const definition of toolDefinitions()) {
|
||||
expect(definition.description.length, definition.name).toBeGreaterThan(
|
||||
20,
|
||||
);
|
||||
expect(definition.parameters.type, definition.name).toBe("object");
|
||||
// additionalProperties:false keeps a model from smuggling extra fields
|
||||
// past the handler's explicit reads.
|
||||
expect(definition.parameters.additionalProperties, definition.name).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("never takes a userId from the model", () => {
|
||||
// Ownership is always derived from the verified JWT. A userId parameter
|
||||
// would let the model ask for another account's data.
|
||||
for (const tool of AI_TOOLS) {
|
||||
const properties = (tool.parameters.properties ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
for (const key of Object.keys(properties)) {
|
||||
expect(
|
||||
/^user_?id$/i.test(key),
|
||||
`${tool.name} accepts a model-supplied ${key}`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("still offers the read tools the assistant needs to be useful", () => {
|
||||
for (const name of ["list_hosts", "list_snippets", "list_automations"]) {
|
||||
expect(getTool(name), name).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compare,
|
||||
extractMetricValue,
|
||||
hasDwelled,
|
||||
isCoolingDown,
|
||||
metricStateKey,
|
||||
severityForValue,
|
||||
type MetricsSnapshot,
|
||||
} from "../../automations/conditions.js";
|
||||
|
||||
const metrics: MetricsSnapshot = {
|
||||
cpu: { percent: 42.5, load: [1.5, 1.2, 0.9] },
|
||||
memory: { percent: 61, usedGiB: 7.5 },
|
||||
disk: {
|
||||
percent: 30,
|
||||
filesystems: [
|
||||
{ mount: "/", percent: 30, availableBytes: 100 },
|
||||
{ mount: "/data", percent: 93.4, availableBytes: 25 },
|
||||
],
|
||||
},
|
||||
network: {
|
||||
interfaces: [
|
||||
{ name: "eth0", rxBytes: "1000", txBytes: "2000" },
|
||||
{ name: "eth1", rxBytes: "50", txBytes: "60" },
|
||||
],
|
||||
},
|
||||
temperature: { highestCelsius: 71 },
|
||||
uptime: { seconds: 86400 },
|
||||
processes: { total: 210 },
|
||||
};
|
||||
|
||||
describe("extractMetricValue", () => {
|
||||
it("reads simple scalar paths", () => {
|
||||
expect(extractMetricValue(metrics, { path: "cpu.percent" })).toBe(42.5);
|
||||
expect(extractMetricValue(metrics, { path: "memory.percent" })).toBe(61);
|
||||
expect(
|
||||
extractMetricValue(metrics, { path: "temperature.highestCelsius" }),
|
||||
).toBe(71);
|
||||
expect(extractMetricValue(metrics, { path: "uptime.seconds" })).toBe(86400);
|
||||
expect(extractMetricValue(metrics, { path: "processes.total" })).toBe(210);
|
||||
});
|
||||
|
||||
it("reads load averages positionally", () => {
|
||||
expect(extractMetricValue(metrics, { path: "cpu.load1" })).toBe(1.5);
|
||||
expect(extractMetricValue(metrics, { path: "cpu.load15" })).toBe(0.9);
|
||||
});
|
||||
|
||||
it("reads a specific mount rather than the aggregate", () => {
|
||||
// The motivating case: /data is nearly full while / is fine.
|
||||
expect(
|
||||
extractMetricValue(metrics, { path: "disk.percent", mount: "/data" }),
|
||||
).toBe(93.4);
|
||||
expect(extractMetricValue(metrics, { path: "disk.percent" })).toBe(30);
|
||||
});
|
||||
|
||||
it("returns null for a mount that is not present", () => {
|
||||
expect(
|
||||
extractMetricValue(metrics, { path: "disk.percent", mount: "/nope" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("selects a named interface and coerces string counters", () => {
|
||||
expect(
|
||||
extractMetricValue(metrics, { path: "network.rxBytes", iface: "eth1" }),
|
||||
).toBe(50);
|
||||
expect(extractMetricValue(metrics, { path: "network.rxBytes" })).toBe(1000);
|
||||
});
|
||||
|
||||
it("extracts per-interface network rates for bandwidth alerts", () => {
|
||||
const rateMetrics = {
|
||||
network: {
|
||||
interfaces: [
|
||||
{ name: "eth0", rxRateBps: 1024, txRateBps: 2048 },
|
||||
{ name: "eth1", rxRateBps: 4096, txRateBps: 8192 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
extractMetricValue(rateMetrics, {
|
||||
path: "network.rxRateBps",
|
||||
iface: "eth1",
|
||||
}),
|
||||
).toBe(4096);
|
||||
expect(
|
||||
extractMetricValue(rateMetrics, {
|
||||
path: "network.txRateBps",
|
||||
iface: "eth0",
|
||||
}),
|
||||
).toBe(2048);
|
||||
});
|
||||
|
||||
it("returns null for missing metrics rather than throwing", () => {
|
||||
expect(extractMetricValue(null, { path: "cpu.percent" })).toBeNull();
|
||||
expect(extractMetricValue({}, { path: "cpu.percent" })).toBeNull();
|
||||
expect(
|
||||
extractMetricValue({ cpu: { percent: null } }, { path: "cpu.percent" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("metricStateKey", () => {
|
||||
it("scopes state per mount so dwell tracks one filesystem", () => {
|
||||
expect(metricStateKey(7, { path: "disk.percent" })).toBe("7");
|
||||
expect(metricStateKey(7, { path: "disk.percent", mount: "/data" })).toBe(
|
||||
"7:/data",
|
||||
);
|
||||
expect(metricStateKey(7, { path: "network.rxBytes", iface: "eth1" })).toBe(
|
||||
"7:eth1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compare", () => {
|
||||
it("handles numeric operators", () => {
|
||||
expect(compare(93, ">", 90)).toBe(true);
|
||||
expect(compare(90, ">", 90)).toBe(false);
|
||||
expect(compare(90, ">=", 90)).toBe(true);
|
||||
expect(compare(10, "<", 90)).toBe(true);
|
||||
expect(compare(90, "<=", 90)).toBe(true);
|
||||
});
|
||||
|
||||
it("compares numeric strings numerically", () => {
|
||||
expect(compare("93", ">", "90")).toBe(true);
|
||||
// Lexically "9" > "10", so this would be wrong as a string compare.
|
||||
expect(compare("9", "<", "10")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to string equality for non-numeric values", () => {
|
||||
expect(compare("running", "==", "running")).toBe(true);
|
||||
expect(compare("running", "!=", "exited")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles containment", () => {
|
||||
expect(compare("disk full", "contains", "full")).toBe(true);
|
||||
expect(compare("disk full", "not_contains", "full")).toBe(false);
|
||||
expect(compare("all good", "not_contains", "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats changed as inequality of the rendered values", () => {
|
||||
expect(compare("online", "changed", "offline")).toBe(true);
|
||||
expect(compare("online", "changed", "online")).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when a numeric comparison has a non-numeric side", () => {
|
||||
expect(compare("abc", ">", 5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCoolingDown", () => {
|
||||
const now = Date.parse("2026-01-01T12:00:00.000Z");
|
||||
|
||||
it("is false when nothing has fired yet", () => {
|
||||
expect(isCoolingDown(null, 15, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true inside the window and false outside it", () => {
|
||||
expect(isCoolingDown("2026-01-01T11:50:00.000Z", 15, now)).toBe(true);
|
||||
expect(isCoolingDown("2026-01-01T11:40:00.000Z", 15, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a zero cooldown as always ready", () => {
|
||||
expect(isCoolingDown("2026-01-01T11:59:59.000Z", 0, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDwelled", () => {
|
||||
const now = Date.parse("2026-01-01T12:00:00.000Z");
|
||||
|
||||
it("fires immediately when no window is configured", () => {
|
||||
expect(hasDwelled(null, undefined, now)).toBe(true);
|
||||
expect(hasDwelled(null, 0, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("requires the window to have elapsed", () => {
|
||||
expect(hasDwelled("2026-01-01T11:49:00.000Z", 600, now)).toBe(true);
|
||||
expect(hasDwelled("2026-01-01T11:55:00.000Z", 600, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when a window is set but no breach is open", () => {
|
||||
expect(hasDwelled(null, 600, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("severityForValue", () => {
|
||||
it("escalates at 95 and honours an explicit override", () => {
|
||||
expect(severityForValue(96)).toBe("critical");
|
||||
expect(severityForValue(90)).toBe("warning");
|
||||
expect(severityForValue(96, "info")).toBe("info");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeNextDueAt,
|
||||
isValidCron,
|
||||
isValidTimezone,
|
||||
nextCronRun,
|
||||
parseCron,
|
||||
} from "../../automations/cron.js";
|
||||
|
||||
describe("parseCron", () => {
|
||||
it("rejects anything that is not five fields", () => {
|
||||
expect(() => parseCron("* * * *")).toThrow(/five fields/);
|
||||
expect(() => parseCron("* * * * * *")).toThrow(/five fields/);
|
||||
});
|
||||
|
||||
it("expands wildcards, lists, ranges and steps", () => {
|
||||
const fields = parseCron("0,30 9-17 * * 1-5");
|
||||
expect([...fields.minutes]).toEqual([0, 30]);
|
||||
expect([...fields.hours]).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]);
|
||||
expect([...fields.daysOfWeek]).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(fields.dowRestricted).toBe(true);
|
||||
expect(fields.domRestricted).toBe(false);
|
||||
});
|
||||
|
||||
it("supports step syntax", () => {
|
||||
expect([...parseCron("*/15 * * * *").minutes]).toEqual([0, 15, 30, 45]);
|
||||
});
|
||||
|
||||
it("accepts month and day names", () => {
|
||||
expect([...parseCron("0 0 1 jan *").months]).toEqual([1]);
|
||||
expect([...parseCron("0 0 * * sun").daysOfWeek]).toEqual([0]);
|
||||
});
|
||||
|
||||
it("treats day 7 as Sunday", () => {
|
||||
expect([...parseCron("0 0 * * 7").daysOfWeek]).toEqual([0]);
|
||||
});
|
||||
|
||||
it("rejects out of range values", () => {
|
||||
expect(() => parseCron("60 * * * *")).toThrow(/out of range/);
|
||||
expect(() => parseCron("* 24 * * *")).toThrow(/out of range/);
|
||||
expect(() => parseCron("* * 0 * *")).toThrow(/out of range/);
|
||||
});
|
||||
|
||||
it("reports validity without throwing", () => {
|
||||
expect(isValidCron("*/5 * * * *")).toBe(true);
|
||||
expect(isValidCron("nonsense")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextCronRun", () => {
|
||||
it("finds the next matching minute", () => {
|
||||
const from = new Date(2026, 0, 1, 10, 3, 30);
|
||||
expect(nextCronRun("*/15 * * * *", from)).toEqual(
|
||||
new Date(2026, 0, 1, 10, 15, 0, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("never returns the starting minute", () => {
|
||||
const from = new Date(2026, 0, 1, 10, 0, 0);
|
||||
expect(nextCronRun("0 * * * *", from)).toEqual(
|
||||
new Date(2026, 0, 1, 11, 0, 0, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("rolls into the next day", () => {
|
||||
const from = new Date(2026, 0, 1, 23, 45, 0);
|
||||
expect(nextCronRun("0 2 * * *", from)).toEqual(
|
||||
new Date(2026, 0, 2, 2, 0, 0, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("unions day-of-month and day-of-week when both are set", () => {
|
||||
// The 15th, or any Monday.
|
||||
const from = new Date(2026, 0, 1, 0, 0, 0);
|
||||
const next = nextCronRun("0 0 15 * 1", from);
|
||||
expect(next).not.toBeNull();
|
||||
const isFifteenth = next!.getDate() === 15;
|
||||
const isMonday = next!.getDay() === 1;
|
||||
expect(isFifteenth || isMonday).toBe(true);
|
||||
});
|
||||
|
||||
it("gives up on a date that can never match", () => {
|
||||
expect(nextCronRun("0 0 30 2 *", new Date(2026, 0, 1))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeNextDueAt", () => {
|
||||
it("prefers an interval over a cron expression", () => {
|
||||
const from = new Date("2026-01-01T00:00:00.000Z");
|
||||
expect(
|
||||
computeNextDueAt({ intervalSeconds: 300, cron: "0 0 * * *" }, from),
|
||||
).toBe("2026-01-01T00:05:00.000Z");
|
||||
});
|
||||
|
||||
it("falls back to cron", () => {
|
||||
const from = new Date(2026, 0, 1, 10, 0, 0);
|
||||
const due = computeNextDueAt({ cron: "30 10 * * *" }, from);
|
||||
expect(due).toBe(new Date(2026, 0, 1, 10, 30, 0, 0).toISOString());
|
||||
});
|
||||
|
||||
it("returns null when nothing is scheduled", () => {
|
||||
expect(computeNextDueAt({})).toBeNull();
|
||||
});
|
||||
|
||||
it("passes the zone through to the cron evaluation", () => {
|
||||
// 02:00 in Tokyo on 2026-06-02 is 17:00 UTC on 2026-06-01.
|
||||
const from = new Date("2026-06-01T00:00:00.000Z");
|
||||
expect(
|
||||
computeNextDueAt({ cron: "0 2 * * *", timezone: "Asia/Tokyo" }, from),
|
||||
).toBe("2026-06-01T17:00:00.000Z");
|
||||
});
|
||||
|
||||
it("ignores the zone for interval schedules", () => {
|
||||
const from = new Date("2026-01-01T00:00:00.000Z");
|
||||
expect(
|
||||
computeNextDueAt({ intervalSeconds: 600, timezone: "Asia/Tokyo" }, from),
|
||||
).toBe("2026-01-01T00:10:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("time zone handling", () => {
|
||||
it("resolves a daily cron against the given zone", () => {
|
||||
const from = new Date("2026-06-01T00:00:00.000Z");
|
||||
// 09:30 New York in June (UTC-4) is 13:30 UTC.
|
||||
const next = nextCronRun("30 9 * * *", from, "America/New_York");
|
||||
expect(next?.toISOString()).toBe("2026-06-01T13:30:00.000Z");
|
||||
});
|
||||
|
||||
it("tracks daylight saving, so the UTC instant shifts by an hour", () => {
|
||||
const summer = nextCronRun(
|
||||
"0 12 * * *",
|
||||
new Date("2026-07-01T00:00:00.000Z"),
|
||||
"America/New_York",
|
||||
);
|
||||
const winter = nextCronRun(
|
||||
"0 12 * * *",
|
||||
new Date("2026-01-01T00:00:00.000Z"),
|
||||
"America/New_York",
|
||||
);
|
||||
|
||||
// Noon local both times, but UTC-4 in July and UTC-5 in January.
|
||||
expect(summer?.toISOString()).toBe("2026-07-01T16:00:00.000Z");
|
||||
expect(winter?.toISOString()).toBe("2026-01-01T17:00:00.000Z");
|
||||
});
|
||||
|
||||
it("matches the day of week in the target zone, not the server's", () => {
|
||||
// 23:00 UTC Sunday is already Monday in Tokyo.
|
||||
const next = nextCronRun(
|
||||
"0 8 * * mon",
|
||||
new Date("2026-06-07T22:00:00.000Z"),
|
||||
"Asia/Tokyo",
|
||||
);
|
||||
expect(next?.toISOString()).toBe("2026-06-07T23:00:00.000Z");
|
||||
});
|
||||
|
||||
it("falls back to server time for an unknown zone instead of throwing", () => {
|
||||
const from = new Date(2026, 0, 1, 10, 0, 0);
|
||||
const next = nextCronRun("30 10 * * *", from, "Not/AZone");
|
||||
expect(next).toEqual(new Date(2026, 0, 1, 10, 30, 0, 0));
|
||||
});
|
||||
|
||||
it("handles midnight, which some hour cycles format as 24", () => {
|
||||
const next = nextCronRun(
|
||||
"0 0 * * *",
|
||||
new Date("2026-06-01T10:00:00.000Z"),
|
||||
"UTC",
|
||||
);
|
||||
expect(next?.toISOString()).toBe("2026-06-02T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidTimezone", () => {
|
||||
it("accepts real zones", () => {
|
||||
expect(isValidTimezone("UTC")).toBe(true);
|
||||
expect(isValidTimezone("Europe/London")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects made-up ones", () => {
|
||||
expect(isValidTimezone("Middle/Earth")).toBe(false);
|
||||
expect(isValidTimezone("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
diffContainerStates,
|
||||
parseContainerStates,
|
||||
} from "../../automations/docker-watcher.js";
|
||||
|
||||
/**
|
||||
* The polling side needs SSH, so what is tested here is the pure part: turning
|
||||
* `docker ps` output into states, and turning two snapshots into events.
|
||||
*/
|
||||
|
||||
describe("parseContainerStates", () => {
|
||||
it("reads name, state and health out of the ps output", () => {
|
||||
const output = [
|
||||
'{"name":"web","state":"running","status":"Up 2 hours"}',
|
||||
'{"name":"db","state":"exited","status":"Exited (0) 5 minutes ago"}',
|
||||
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}',
|
||||
].join("\n");
|
||||
|
||||
const states = parseContainerStates(output);
|
||||
|
||||
expect(states.get("web")).toEqual({ state: "running", unhealthy: false });
|
||||
expect(states.get("db")).toEqual({ state: "exited", unhealthy: false });
|
||||
expect(states.get("api")).toEqual({ state: "running", unhealthy: true });
|
||||
});
|
||||
|
||||
it("skips blank and malformed lines rather than failing the poll", () => {
|
||||
const output = [
|
||||
'{"name":"web","state":"running","status":"Up"}',
|
||||
"",
|
||||
"not json at all",
|
||||
'{"state":"running"}',
|
||||
].join("\n");
|
||||
|
||||
const states = parseContainerStates(output);
|
||||
expect([...states.keys()]).toEqual(["web"]);
|
||||
});
|
||||
|
||||
it("returns nothing for empty output", () => {
|
||||
expect(parseContainerStates("").size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffContainerStates", () => {
|
||||
const running = { state: "running", unhealthy: false };
|
||||
const exited = { state: "exited", unhealthy: false };
|
||||
|
||||
it("reports a container that stopped", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", exited]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "exited" }]);
|
||||
});
|
||||
|
||||
it("reports a container that started", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", exited]]),
|
||||
new Map([["web", running]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "started" }]);
|
||||
});
|
||||
|
||||
it("reports a container that began restarting", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", { state: "restarting", unhealthy: false }]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "restarting" }]);
|
||||
});
|
||||
|
||||
it("reports a container that went unhealthy while still running", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", { state: "running", unhealthy: true }]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "unhealthy" }]);
|
||||
});
|
||||
|
||||
it("stays quiet when nothing changed", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", running]]),
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not re-announce a container that is still unhealthy", () => {
|
||||
const unhealthy = { state: "running", unhealthy: true };
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", unhealthy]]),
|
||||
new Map([["web", unhealthy]]),
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
// A first sighting is a baseline, not an event: otherwise every container
|
||||
// running at boot would report itself as freshly started.
|
||||
it("treats a newly seen container as a baseline", () => {
|
||||
const events = diffContainerStates(new Map(), new Map([["web", running]]));
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a container that disappeared", () => {
|
||||
const events = diffContainerStates(new Map([["web", running]]), new Map());
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AutomationDefinition, Step } from "../../../types/automations.js";
|
||||
|
||||
/**
|
||||
* The engine reaches the database through the repository factory and the
|
||||
* outside world through the step executors, so both are mocked here. What is
|
||||
* under test is the run loop itself: ordering, branching, error policy,
|
||||
* concurrency, recursion and dry-run.
|
||||
*/
|
||||
|
||||
interface FakeAutomation {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
definition: string;
|
||||
concurrencyPolicy: string;
|
||||
maxRunSeconds: number;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
const automations = new Map<number, FakeAutomation>();
|
||||
const runs: Array<Record<string, unknown>> = [];
|
||||
const runSteps: Array<Record<string, unknown>> = [];
|
||||
let nextRunId = 1;
|
||||
let nextStepRowId = 1;
|
||||
|
||||
const repository = {
|
||||
findById: vi.fn(async (id: number) => automations.get(id) ?? null),
|
||||
createRun: vi.fn(async (input: Record<string, unknown>) => {
|
||||
const run = { id: nextRunId++, ...input };
|
||||
runs.push(run);
|
||||
return run;
|
||||
}),
|
||||
finishRun: vi.fn(async (runId: number, input: Record<string, unknown>) => {
|
||||
const run = runs.find((entry) => entry.id === runId);
|
||||
if (run) Object.assign(run, input);
|
||||
}),
|
||||
createRunStep: vi.fn(async (input: Record<string, unknown>) => {
|
||||
const id = nextStepRowId++;
|
||||
runSteps.push({ id, ...input });
|
||||
return id;
|
||||
}),
|
||||
finishRunStep: vi.fn(async (id: number, input: Record<string, unknown>) => {
|
||||
const step = runSteps.find((entry) => entry.id === id);
|
||||
if (step) Object.assign(step, input);
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const executeStep = vi.fn();
|
||||
vi.mock("../../automations/actions/index.js", () => ({
|
||||
executeStep: (...args: unknown[]) => executeStep(...args),
|
||||
}));
|
||||
|
||||
const { AutomationEngine } = await import("../../automations/engine.js");
|
||||
|
||||
function defineAutomation(
|
||||
steps: Step[],
|
||||
overrides: Partial<FakeAutomation> = {},
|
||||
): FakeAutomation {
|
||||
const definition: AutomationDefinition = {
|
||||
version: 1,
|
||||
trigger: { kind: "webhook", tokenHash: "x" },
|
||||
steps,
|
||||
};
|
||||
const automation: FakeAutomation = {
|
||||
id: overrides.id ?? 1,
|
||||
userId: "user-1",
|
||||
name: "Test",
|
||||
enabled: true,
|
||||
definition: JSON.stringify(definition),
|
||||
concurrencyPolicy: "skip",
|
||||
maxRunSeconds: 300,
|
||||
dryRun: false,
|
||||
...overrides,
|
||||
};
|
||||
automations.set(automation.id, automation);
|
||||
return automation;
|
||||
}
|
||||
|
||||
function step(partial: Partial<Step> & { id: string; type: string }): Step {
|
||||
return partial as Step;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
automations.clear();
|
||||
runs.length = 0;
|
||||
runSteps.length = 0;
|
||||
nextRunId = 1;
|
||||
nextStepRowId = 1;
|
||||
vi.clearAllMocks();
|
||||
executeStep.mockResolvedValue({ success: true, output: "ok" });
|
||||
// The singleton carries in-flight state between tests.
|
||||
(AutomationEngine as unknown as { instance?: unknown }).instance = undefined;
|
||||
});
|
||||
|
||||
describe("AutomationEngine.run", () => {
|
||||
it("runs steps in order and records each one", async () => {
|
||||
defineAutomation([
|
||||
step({ id: "a", type: "run_command" }),
|
||||
step({ id: "b", type: "http" }),
|
||||
]);
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("success");
|
||||
expect(executeStep).toHaveBeenCalledTimes(2);
|
||||
expect(runSteps.map((s) => s.stepId)).toEqual(["a", "b"]);
|
||||
expect(runSteps.map((s) => s.stepIndex)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("fails the run and stops when a step fails under the default policy", async () => {
|
||||
defineAutomation([
|
||||
step({ id: "a", type: "run_command" }),
|
||||
step({ id: "b", type: "http" }),
|
||||
]);
|
||||
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(outcome.error).toBe("boom");
|
||||
expect(executeStep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps going when a step is marked continue-on-error", async () => {
|
||||
defineAutomation([
|
||||
step({ id: "a", type: "run_command", onError: "continue" }),
|
||||
step({ id: "b", type: "http" }),
|
||||
]);
|
||||
executeStep.mockResolvedValueOnce({ success: false, error: "boom" });
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("success");
|
||||
expect(executeStep).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips disabled steps", async () => {
|
||||
defineAutomation([
|
||||
step({ id: "a", type: "run_command", enabled: false }),
|
||||
step({ id: "b", type: "http" }),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(executeStep).toHaveBeenCalledTimes(1);
|
||||
expect(runSteps.map((s) => s.stepId)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("passes earlier step output to later steps", async () => {
|
||||
defineAutomation([
|
||||
step({ id: "first", type: "run_command" }),
|
||||
step({ id: "second", type: "http" }),
|
||||
]);
|
||||
executeStep.mockResolvedValueOnce({ success: true, output: "hello" });
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
const secondCallContext = executeStep.mock.calls[1][1] as {
|
||||
template: { steps: Record<string, { stdout: string }> };
|
||||
};
|
||||
expect(secondCallContext.template.steps.first.stdout).toBe("hello");
|
||||
});
|
||||
|
||||
it("merges variables set by a step into the template context", async () => {
|
||||
defineAutomation([
|
||||
step({ id: "setter", type: "set_var" }),
|
||||
step({ id: "next", type: "http" }),
|
||||
]);
|
||||
executeStep.mockResolvedValueOnce({
|
||||
success: true,
|
||||
output: "x = 1",
|
||||
vars: { x: "1" },
|
||||
});
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
const context = executeStep.mock.calls[1][1] as {
|
||||
template: { vars: Record<string, string> };
|
||||
};
|
||||
expect(context.template.vars.x).toBe("1");
|
||||
});
|
||||
|
||||
describe("if branching", () => {
|
||||
it("runs the then branch when the condition matches", async () => {
|
||||
defineAutomation([
|
||||
step({
|
||||
id: "cond",
|
||||
type: "if",
|
||||
condition: { left: "93", operator: ">", right: "90" },
|
||||
then: [step({ id: "yes", type: "http" })],
|
||||
else: [step({ id: "no", type: "http" })],
|
||||
}),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(executeStep).toHaveBeenCalledTimes(1);
|
||||
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "yes"]);
|
||||
});
|
||||
|
||||
it("runs the else branch when it does not", async () => {
|
||||
defineAutomation([
|
||||
step({
|
||||
id: "cond",
|
||||
type: "if",
|
||||
condition: { left: "10", operator: ">", right: "90" },
|
||||
then: [step({ id: "yes", type: "http" })],
|
||||
else: [step({ id: "no", type: "http" })],
|
||||
}),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "no"]);
|
||||
});
|
||||
|
||||
it("resolves templates on both sides of the condition", async () => {
|
||||
defineAutomation([
|
||||
step({
|
||||
id: "cond",
|
||||
type: "if",
|
||||
condition: {
|
||||
left: "{{trigger.value}}",
|
||||
operator: ">=",
|
||||
right: "90",
|
||||
},
|
||||
then: [step({ id: "yes", type: "http" })],
|
||||
}),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "metric_threshold",
|
||||
triggerContext: { value: 95 },
|
||||
});
|
||||
|
||||
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "yes"]);
|
||||
});
|
||||
|
||||
it("treats an empty else as a no-op", async () => {
|
||||
defineAutomation([
|
||||
step({
|
||||
id: "cond",
|
||||
type: "if",
|
||||
condition: { left: "1", operator: "==", right: "2" },
|
||||
then: [step({ id: "yes", type: "http" })],
|
||||
}),
|
||||
step({ id: "after", type: "http" }),
|
||||
]);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(runSteps.map((s) => s.stepId)).toEqual(["cond", "after"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("halts the run when a step returns a stop signal", async () => {
|
||||
defineAutomation([
|
||||
step({ id: "a", type: "run_command" }),
|
||||
step({ id: "b", type: "http" }),
|
||||
]);
|
||||
executeStep.mockResolvedValueOnce({
|
||||
success: true,
|
||||
halt: { status: "success" },
|
||||
});
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("success");
|
||||
expect(executeStep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("marks the run failed when a stop step asks for failure", async () => {
|
||||
defineAutomation([step({ id: "a", type: "run_command" })]);
|
||||
executeStep.mockResolvedValueOnce({
|
||||
success: true,
|
||||
halt: { status: "failed" },
|
||||
});
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("failed");
|
||||
});
|
||||
|
||||
describe("recursion protection", () => {
|
||||
it("refuses to re-enter an automation already in the chain", async () => {
|
||||
defineAutomation([
|
||||
step({ id: "nested", type: "run_automation", automationId: 1 }),
|
||||
]);
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
// The nested call is refused, and the refusal is recorded on the step.
|
||||
const nestedStep = runSteps.find((s) => s.stepId === "nested");
|
||||
expect(nestedStep?.status).toBe("failed");
|
||||
expect(String(nestedStep?.error)).toMatch(
|
||||
/already running in this chain/,
|
||||
);
|
||||
expect(outcome.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("refuses a mutual cycle between two automations", async () => {
|
||||
defineAutomation(
|
||||
[step({ id: "toB", type: "run_automation", automationId: 2 })],
|
||||
{ id: 1 },
|
||||
);
|
||||
defineAutomation(
|
||||
[step({ id: "toA", type: "run_automation", automationId: 1 })],
|
||||
{ id: 2 },
|
||||
);
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
const inner = runSteps.find((s) => s.stepId === "toA");
|
||||
expect(inner?.status).toBe("failed");
|
||||
expect(String(inner?.error)).toMatch(/already running in this chain/);
|
||||
});
|
||||
|
||||
it("refuses to nest deeper than the maximum depth", async () => {
|
||||
defineAutomation([step({ id: "a", type: "http" })]);
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
depth: 99,
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(outcome.error).toMatch(/depth/);
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("concurrency", () => {
|
||||
it("records a skipped run rather than dropping it silently", async () => {
|
||||
defineAutomation([step({ id: "slow", type: "run_command" })], {
|
||||
concurrencyPolicy: "skip",
|
||||
});
|
||||
|
||||
let release: () => void = () => {};
|
||||
executeStep.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = () => resolve({ success: true, output: "done" });
|
||||
}),
|
||||
);
|
||||
|
||||
const engine = AutomationEngine.getInstance();
|
||||
const first = engine.run({ automationId: 1, triggerType: "manual" });
|
||||
// Let the first run register itself as in flight.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const second = await engine.run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
expect(second.status).toBe("skipped");
|
||||
|
||||
release();
|
||||
await first;
|
||||
|
||||
const skipped = runs.find((run) => run.status === "skipped");
|
||||
expect(skipped).toBeDefined();
|
||||
expect(String(skipped?.error)).toMatch(/still in progress/);
|
||||
});
|
||||
|
||||
it("skips the second of two triggers that arrive in the same tick", async () => {
|
||||
defineAutomation([step({ id: "slow", type: "run_command" })], {
|
||||
concurrencyPolicy: "skip",
|
||||
});
|
||||
|
||||
executeStep.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve({ success: true, output: "done" }), 20),
|
||||
),
|
||||
);
|
||||
|
||||
// No gap between the two: the in-flight slot used to be claimed several
|
||||
// awaits after it was checked, so both runs got through.
|
||||
const engine = AutomationEngine.getInstance();
|
||||
const [first, second] = await Promise.all([
|
||||
engine.run({ automationId: 1, triggerType: "manual" }),
|
||||
engine.run({ automationId: 1, triggerType: "manual" }),
|
||||
]);
|
||||
|
||||
expect([first.status, second.status].sort()).toEqual([
|
||||
"skipped",
|
||||
"success",
|
||||
]);
|
||||
expect(executeStep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("releases the in-flight slot when the run row cannot be created", async () => {
|
||||
defineAutomation([step({ id: "a", type: "run_command" })]);
|
||||
repository.createRun.mockRejectedValueOnce(new Error("db down"));
|
||||
|
||||
const engine = AutomationEngine.getInstance();
|
||||
const failed = await engine.run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
expect(failed.status).toBe("failed");
|
||||
|
||||
// A leaked slot would make every later run skip forever.
|
||||
const after = await engine.run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
expect(after.status).toBe("success");
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates the dry-run flag to executors", async () => {
|
||||
defineAutomation([step({ id: "a", type: "http" })], { dryRun: true });
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
const context = executeStep.mock.calls[0][1] as { dryRun: boolean };
|
||||
expect(context.dryRun).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a caller force a dry run on a live automation", async () => {
|
||||
defineAutomation([step({ id: "a", type: "http" })], { dryRun: false });
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
const context = executeStep.mock.calls[0][1] as { dryRun: boolean };
|
||||
expect(context.dryRun).toBe(true);
|
||||
});
|
||||
|
||||
it("fails cleanly when the automation is missing", async () => {
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: 404,
|
||||
triggerType: "manual",
|
||||
});
|
||||
expect(outcome).toMatchObject({ status: "failed", runId: null });
|
||||
});
|
||||
|
||||
it("fails cleanly when the definition is not valid JSON", async () => {
|
||||
automations.set(1, {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
name: "Broken",
|
||||
enabled: true,
|
||||
definition: "not json",
|
||||
concurrencyPolicy: "skip",
|
||||
maxRunSeconds: 300,
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(outcome.error).toMatch(/not valid JSON/);
|
||||
});
|
||||
|
||||
it("turns a thrown executor error into a failed step", async () => {
|
||||
defineAutomation([step({ id: "a", type: "run_command" })]);
|
||||
executeStep.mockRejectedValueOnce(new Error("connection reset"));
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(runSteps[0].status).toBe("failed");
|
||||
expect(String(runSteps[0].error)).toMatch(/connection reset/);
|
||||
});
|
||||
|
||||
it("truncates very large step output", async () => {
|
||||
defineAutomation([step({ id: "a", type: "run_command" })]);
|
||||
executeStep.mockResolvedValueOnce({
|
||||
success: true,
|
||||
output: "x".repeat(40_000),
|
||||
});
|
||||
|
||||
await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(runSteps[0].truncated).toBe(true);
|
||||
expect(String(runSteps[0].output)).toMatch(/truncated/);
|
||||
});
|
||||
|
||||
it("stops once the run deadline has passed", async () => {
|
||||
defineAutomation(
|
||||
[step({ id: "a", type: "run_command" }), step({ id: "b", type: "http" })],
|
||||
{ maxRunSeconds: 1 },
|
||||
);
|
||||
|
||||
// The first step consumes the whole budget, so the second must not start.
|
||||
executeStep.mockImplementationOnce(async () => {
|
||||
vi.setSystemTime(Date.now() + 5_000);
|
||||
return { success: true, output: "slow" };
|
||||
});
|
||||
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: 1,
|
||||
triggerType: "manual",
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("timeout");
|
||||
expect(executeStep).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listAutomationWatchedHosts = vi.fn();
|
||||
vi.mock("../../automations/triggers.js", () => ({
|
||||
listAutomationWatchedHosts: () => listAutomationWatchedHosts(),
|
||||
}));
|
||||
|
||||
const {
|
||||
automationSessionId,
|
||||
reconcileHeadlessViewers,
|
||||
releaseHeadlessViewers,
|
||||
setViewerRegistry,
|
||||
} = await import("../../automations/headless-viewer.js");
|
||||
|
||||
const registry = {
|
||||
registerViewer: vi.fn(),
|
||||
unregisterViewer: vi.fn(),
|
||||
updateHeartbeat: vi.fn(() => true),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Drop any viewers the previous test left behind before the mocks are
|
||||
// cleared, so those unregister calls are not counted against this test.
|
||||
releaseHeadlessViewers();
|
||||
vi.clearAllMocks();
|
||||
setViewerRegistry(registry);
|
||||
listAutomationWatchedHosts.mockResolvedValue(new Map());
|
||||
});
|
||||
|
||||
describe("reconcileHeadlessViewers", () => {
|
||||
it("registers a synthetic viewer for each watched host", async () => {
|
||||
listAutomationWatchedHosts.mockResolvedValue(
|
||||
new Map([
|
||||
[7, "user-1"],
|
||||
[9, "user-2"],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await reconcileHeadlessViewers();
|
||||
|
||||
expect(result.added).toBe(2);
|
||||
expect(registry.registerViewer).toHaveBeenCalledWith(
|
||||
7,
|
||||
"automation:7",
|
||||
"user-1",
|
||||
);
|
||||
expect(registry.registerViewer).toHaveBeenCalledWith(
|
||||
9,
|
||||
"automation:9",
|
||||
"user-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("heartbeats instead of re-registering a host it already holds", async () => {
|
||||
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
|
||||
await reconcileHeadlessViewers();
|
||||
registry.registerViewer.mockClear();
|
||||
|
||||
const second = await reconcileHeadlessViewers();
|
||||
|
||||
// The 120s reaper drops viewers with a stale heartbeat, so every tick has
|
||||
// to refresh the ones it is keeping.
|
||||
expect(registry.updateHeartbeat).toHaveBeenCalledWith("automation:7");
|
||||
expect(registry.registerViewer).not.toHaveBeenCalled();
|
||||
expect(second.added).toBe(0);
|
||||
expect(second.active).toBe(1);
|
||||
});
|
||||
|
||||
it("releases a viewer once no automation watches the host", async () => {
|
||||
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
|
||||
await reconcileHeadlessViewers();
|
||||
|
||||
listAutomationWatchedHosts.mockResolvedValue(new Map());
|
||||
const result = await reconcileHeadlessViewers();
|
||||
|
||||
expect(result.removed).toBe(1);
|
||||
expect(result.active).toBe(0);
|
||||
expect(registry.unregisterViewer).toHaveBeenCalledWith(7, "automation:7");
|
||||
});
|
||||
|
||||
it("does nothing when no registry has been wired up", async () => {
|
||||
setViewerRegistry(null);
|
||||
listAutomationWatchedHosts.mockResolvedValue(new Map([[7, "user-1"]]));
|
||||
|
||||
const result = await reconcileHeadlessViewers();
|
||||
|
||||
expect(result).toEqual({ added: 0, removed: 0, active: 0 });
|
||||
expect(registry.registerViewer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps going when the watch list cannot be loaded", async () => {
|
||||
listAutomationWatchedHosts.mockRejectedValue(new Error("db down"));
|
||||
await expect(reconcileHeadlessViewers()).resolves.toMatchObject({
|
||||
added: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("survives a registry that throws on register", async () => {
|
||||
listAutomationWatchedHosts.mockResolvedValue(
|
||||
new Map([
|
||||
[7, "user-1"],
|
||||
[8, "user-1"],
|
||||
]),
|
||||
);
|
||||
registry.registerViewer.mockImplementationOnce(() => {
|
||||
throw new Error("nope");
|
||||
});
|
||||
|
||||
const result = await reconcileHeadlessViewers();
|
||||
|
||||
// One host failing must not stop the other from being registered.
|
||||
expect(result.added).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseHeadlessViewers", () => {
|
||||
it("drops every viewer it is holding", async () => {
|
||||
listAutomationWatchedHosts.mockResolvedValue(
|
||||
new Map([
|
||||
[7, "user-1"],
|
||||
[8, "user-1"],
|
||||
]),
|
||||
);
|
||||
await reconcileHeadlessViewers();
|
||||
|
||||
releaseHeadlessViewers();
|
||||
|
||||
expect(registry.unregisterViewer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("automationSessionId", () => {
|
||||
it("namespaces the session so it cannot collide with a real viewer", () => {
|
||||
expect(automationSessionId(42)).toBe("automation:42");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasUnresolvedTokens,
|
||||
redactSecrets,
|
||||
renderRecord,
|
||||
renderTemplate,
|
||||
} from "../../automations/template.js";
|
||||
|
||||
const context = {
|
||||
host: { id: 7, name: "Zeus", ip: "10.0.0.5", username: "root", port: 22 },
|
||||
trigger: { value: 93.4, mount: "/data" },
|
||||
steps: { check: { stdout: "ok\n", stderr: "", code: 0 } },
|
||||
vars: { target: "/var/log" },
|
||||
};
|
||||
|
||||
describe("renderTemplate", () => {
|
||||
it("substitutes host, trigger, step and var tokens", () => {
|
||||
expect(renderTemplate("{{host.name}} at {{trigger.value}}%", context)).toBe(
|
||||
"Zeus at 93.4%",
|
||||
);
|
||||
expect(renderTemplate("{{steps.check.stdout}}", context)).toBe("ok\n");
|
||||
expect(renderTemplate("{{vars.target}}", context)).toBe("/var/log");
|
||||
});
|
||||
|
||||
it("tolerates whitespace inside the braces", () => {
|
||||
expect(renderTemplate("{{ host.name }}", context)).toBe("Zeus");
|
||||
});
|
||||
|
||||
it("leaves unknown tokens in place so typos are visible", () => {
|
||||
expect(renderTemplate("{{host.nope}}", context)).toBe("{{host.nope}}");
|
||||
expect(hasUnresolvedTokens(renderTemplate("{{host.nope}}", context))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the input untouched when there is nothing to render", () => {
|
||||
expect(renderTemplate("plain text", context)).toBe("plain text");
|
||||
expect(renderTemplate("", context)).toBe("");
|
||||
});
|
||||
|
||||
it("does not escape or alter shell metacharacters", () => {
|
||||
// Quoting is the caller's job; this keeps that boundary explicit.
|
||||
const rendered = renderTemplate("{{vars.payload}}", {
|
||||
vars: { payload: "; rm -rf /" },
|
||||
});
|
||||
expect(rendered).toBe("; rm -rf /");
|
||||
});
|
||||
|
||||
it("does not recursively expand a value that looks like a token", () => {
|
||||
const rendered = renderTemplate("{{vars.a}}", {
|
||||
vars: { a: "{{vars.b}}", b: "gotcha" },
|
||||
});
|
||||
expect(rendered).toBe("{{vars.b}}");
|
||||
});
|
||||
|
||||
it("serializes objects rather than printing [object Object]", () => {
|
||||
expect(renderTemplate("{{trigger}}", { trigger: { a: 1 } })).toBe(
|
||||
'{"a":1}',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders missing branches as the literal token", () => {
|
||||
expect(renderTemplate("{{steps.other.stdout}}", context)).toBe(
|
||||
"{{steps.other.stdout}}",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderRecord", () => {
|
||||
it("renders values and leaves keys alone", () => {
|
||||
expect(renderRecord({ "X-Host": "{{host.name}}" }, context)).toEqual({
|
||||
"X-Host": "Zeus",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes undefined through", () => {
|
||||
expect(renderRecord(undefined, context)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactSecrets", () => {
|
||||
it("masks credential-shaped keys", () => {
|
||||
expect(
|
||||
redactSecrets({
|
||||
Authorization: "Bearer abc",
|
||||
"X-Api-Key": "k",
|
||||
token: "t",
|
||||
password: "p",
|
||||
Accept: "application/json",
|
||||
}),
|
||||
).toEqual({
|
||||
Authorization: "***",
|
||||
"X-Api-Key": "***",
|
||||
token: "***",
|
||||
password: "***",
|
||||
Accept: "application/json",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Trigger,
|
||||
} from "../../../types/automations.js";
|
||||
|
||||
/**
|
||||
* Trigger matching, dwell windows and cooldowns. The repository and the engine
|
||||
* are mocked so these tests only exercise the decision of whether to fire.
|
||||
*/
|
||||
|
||||
interface FakeRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
definition: string;
|
||||
concurrencyPolicy: string;
|
||||
maxRunSeconds: number;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
const rows: FakeRow[] = [];
|
||||
const triggerState = new Map<string, Record<string, unknown>>();
|
||||
|
||||
const repository = {
|
||||
listEnabledForUser: vi.fn(async (userId: string) =>
|
||||
rows.filter((row) => row.userId === userId && row.enabled),
|
||||
),
|
||||
listAllEnabled: vi.fn(async () => rows.filter((row) => row.enabled)),
|
||||
getTriggerState: vi.fn(async (automationId: number, stateKey: string) => {
|
||||
return triggerState.get(`${automationId}:${stateKey}`) ?? null;
|
||||
}),
|
||||
upsertTriggerState: vi.fn(async (input: Record<string, unknown>) => {
|
||||
const key = `${input.automationId}:${input.stateKey}`;
|
||||
triggerState.set(key, { ...(triggerState.get(key) ?? {}), ...input });
|
||||
}),
|
||||
clearBreach: vi.fn(async (automationId: number, stateKey: string) => {
|
||||
const key = `${automationId}:${stateKey}`;
|
||||
const existing = triggerState.get(key);
|
||||
if (existing) existing.breachStartedAt = null;
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
|
||||
vi.mock("../../automations/engine.js", () => ({
|
||||
AutomationEngine: { getInstance: () => ({ run }) },
|
||||
}));
|
||||
|
||||
const triggers = await import("../../automations/triggers.js");
|
||||
|
||||
function addAutomation(trigger: Trigger, overrides: Partial<FakeRow> = {}) {
|
||||
const definition: AutomationDefinition = { version: 1, trigger, steps: [] };
|
||||
const row: FakeRow = {
|
||||
id: overrides.id ?? rows.length + 1,
|
||||
userId: overrides.userId ?? "user-1",
|
||||
name: "Test",
|
||||
enabled: overrides.enabled ?? true,
|
||||
definition: JSON.stringify(definition),
|
||||
concurrencyPolicy: "skip",
|
||||
maxRunSeconds: 300,
|
||||
dryRun: false,
|
||||
};
|
||||
rows.push(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
const diskMetrics = {
|
||||
disk: {
|
||||
percent: 30,
|
||||
filesystems: [
|
||||
{ mount: "/", percent: 30 },
|
||||
{ mount: "/data", percent: 93 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
rows.length = 0;
|
||||
triggerState.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("onMetrics", () => {
|
||||
it("fires immediately when no dwell window is set", async () => {
|
||||
addAutomation({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
metric: { path: "disk.percent", mount: "/data" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
|
||||
await triggers.onMetrics({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
metrics: diskMetrics,
|
||||
});
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run.mock.calls[0][0]).toMatchObject({
|
||||
triggerType: "metric_threshold",
|
||||
triggerHostId: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("watches the named mount rather than the aggregate", async () => {
|
||||
// Root is at 30%, so a rule on / must not fire at a 90% threshold.
|
||||
addAutomation({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
metric: { path: "disk.percent", mount: "/" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
|
||||
await triggers.onMetrics({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
metrics: diskMetrics,
|
||||
});
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens a dwell window instead of firing on the first sample", async () => {
|
||||
const row = addAutomation({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
metric: { path: "disk.percent", mount: "/data" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
forSeconds: 600,
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
|
||||
await triggers.onMetrics({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
metrics: diskMetrics,
|
||||
});
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(triggerState.get(`${row.id}:7:/data`)).toMatchObject({
|
||||
breachStartedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("fires once the dwell window has elapsed", async () => {
|
||||
const row = addAutomation({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
metric: { path: "disk.percent", mount: "/data" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
forSeconds: 600,
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
triggerState.set(`${row.id}:7:/data`, {
|
||||
breachStartedAt: new Date(Date.now() - 700_000).toISOString(),
|
||||
});
|
||||
|
||||
await triggers.onMetrics({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
metrics: diskMetrics,
|
||||
});
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears the window as soon as the value recovers", async () => {
|
||||
const row = addAutomation({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
metric: { path: "disk.percent", mount: "/data" },
|
||||
operator: ">",
|
||||
value: 95,
|
||||
forSeconds: 600,
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
triggerState.set(`${row.id}:7:/data`, {
|
||||
breachStartedAt: new Date(Date.now() - 700_000).toISOString(),
|
||||
});
|
||||
|
||||
await triggers.onMetrics({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
metrics: diskMetrics,
|
||||
});
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(repository.clearBreach).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays quiet while the cooldown is open", async () => {
|
||||
const row = addAutomation({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
metric: { path: "disk.percent", mount: "/data" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
triggerState.set(`${row.id}:7:/data`, {
|
||||
lastFiredAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
breachStartedAt: new Date(Date.now() - 700_000).toISOString(),
|
||||
});
|
||||
|
||||
await triggers.onMetrics({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
metrics: diskMetrics,
|
||||
});
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores hosts outside the selector", async () => {
|
||||
addAutomation({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 99 },
|
||||
metric: { path: "disk.percent", mount: "/data" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
|
||||
await triggers.onMetrics({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
metrics: diskMetrics,
|
||||
});
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never evaluates another user's automations", async () => {
|
||||
addAutomation(
|
||||
{
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "all" },
|
||||
metric: { path: "disk.percent", mount: "/data" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
},
|
||||
{ userId: "user-2" },
|
||||
);
|
||||
|
||||
await triggers.onMetrics({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
metrics: diskMetrics,
|
||||
});
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onStatus", () => {
|
||||
it("treats the first observation as a baseline", async () => {
|
||||
addAutomation({
|
||||
kind: "host_status",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
to: "offline",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
|
||||
await triggers.onStatus({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
online: false,
|
||||
});
|
||||
|
||||
// Otherwise every host would announce itself after a restart.
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires on a transition into the watched state", async () => {
|
||||
const row = addAutomation({
|
||||
kind: "host_status",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
to: "offline",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
triggerState.set(`${row.id}:7`, { lastObservedState: "online" });
|
||||
|
||||
await triggers.onStatus({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
online: false,
|
||||
});
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fire on a transition in the other direction", async () => {
|
||||
const row = addAutomation({
|
||||
kind: "host_status",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
to: "offline",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
triggerState.set(`${row.id}:7`, { lastObservedState: "offline" });
|
||||
|
||||
await triggers.onStatus({ hostId: 7, ownerUserId: "user-1", online: true });
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays quiet while the state is unchanged", async () => {
|
||||
const row = addAutomation({
|
||||
kind: "host_status",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
to: "offline",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
triggerState.set(`${row.id}:7`, { lastObservedState: "offline" });
|
||||
|
||||
await triggers.onStatus({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
online: false,
|
||||
});
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onHealthCheck", () => {
|
||||
it("fires when a check transitions to failing", async () => {
|
||||
const row = addAutomation({
|
||||
kind: "health_check",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
to: "failing",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
triggerState.set(`${row.id}:7:web`, { lastObservedState: "recovered" });
|
||||
|
||||
await triggers.onHealthCheck({
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
checkId: "web",
|
||||
ok: false,
|
||||
});
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores a different check id", async () => {
|
||||
const row = addAutomation({
|
||||
kind: "health_check",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
checkId: "db",
|
||||
to: "failing",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
triggerState.set(`${row.id}:7:web`, { lastObservedState: "recovered" });
|
||||
|
||||
await triggers.onHealthCheck({
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
checkId: "web",
|
||||
ok: false,
|
||||
});
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onDockerEvent", () => {
|
||||
it("fires for a matching container and event", async () => {
|
||||
addAutomation({
|
||||
kind: "docker_event",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
container: "api",
|
||||
event: "exited",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
|
||||
await triggers.onDockerEvent({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
container: "api",
|
||||
event: "exited",
|
||||
});
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores a different container", async () => {
|
||||
addAutomation({
|
||||
kind: "docker_event",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
container: "api",
|
||||
event: "exited",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
|
||||
await triggers.onDockerEvent({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
container: "worker",
|
||||
event: "exited",
|
||||
});
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAutomationWatchedHosts", () => {
|
||||
it("collects hosts from metric triggers so they can be polled headlessly", async () => {
|
||||
addAutomation({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "hosts", hostIds: [3, 4] },
|
||||
metric: { path: "cpu.percent" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
|
||||
const watched = await triggers.listAutomationWatchedHosts();
|
||||
|
||||
expect([...watched.keys()].sort()).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it("ignores triggers that do not need heavy collection", async () => {
|
||||
addAutomation({
|
||||
kind: "host_status",
|
||||
hostSelector: { kind: "host", hostId: 3 },
|
||||
to: "offline",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
|
||||
expect((await triggers.listAutomationWatchedHosts()).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The audit trail outlives the account it belongs to: deleting a user
|
||||
* anonymises their entries by nulling `user_id` and leaving `username` behind.
|
||||
*
|
||||
* The Drizzle schema said so, the repository was written against it, but the
|
||||
* runtime bootstrap still created `user_id TEXT NOT NULL`. A second, corrected
|
||||
* `CREATE TABLE IF NOT EXISTS` further down was a no-op — the table already
|
||||
* existed — so every fresh install got the old constraint and every user
|
||||
* deletion (including the OIDC account-link cleanup) failed once the account
|
||||
* had logged in at least once.
|
||||
*/
|
||||
describe("audit_logs.user_id is nullable", () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-audit-schema-"));
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DB_FILE_ENCRYPTION = "false";
|
||||
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_FILE_ENCRYPTION;
|
||||
delete process.env.ALLOW_EMPTY_DATA_DIR;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function userIdIsNotNull(sqlite: Database.Database): boolean {
|
||||
const columns = sqlite.prepare("PRAGMA table_info(audit_logs)").all() as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
}>;
|
||||
return columns.find((col) => col.name === "user_id")?.notnull === 1;
|
||||
}
|
||||
|
||||
it("lets a fresh install outlive the account it logged", async () => {
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
const sqlite = db.getSqlite();
|
||||
|
||||
expect(userIdIsNotNull(sqlite)).toBe(false);
|
||||
|
||||
sqlite
|
||||
.prepare("INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)")
|
||||
.run("user-1", "alice", "hash");
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO audit_logs (user_id, username, action, resource_type, success)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run("user-1", "alice", "login", "auth", 1);
|
||||
|
||||
expect(() => sqlite.prepare("DELETE FROM users WHERE id = ?").run("user-1")).not.toThrow();
|
||||
|
||||
const row = sqlite.prepare("SELECT user_id, username FROM audit_logs").get() as {
|
||||
user_id: string | null;
|
||||
username: string;
|
||||
};
|
||||
|
||||
// The reference goes, the attribution stays.
|
||||
expect(row.user_id).toBeNull();
|
||||
expect(row.username).toBe("alice");
|
||||
});
|
||||
|
||||
it("rebuilds an existing table that still has the constraint, keeping its rows", async () => {
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec(`
|
||||
CREATE TABLE audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
resource_name TEXT,
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
seed
|
||||
.prepare(
|
||||
`INSERT INTO audit_logs (user_id, username, action, resource_type, success)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run("user-1", "alice", "login", "auth", 1);
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||
seed.close();
|
||||
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
const sqlite = db.getSqlite();
|
||||
|
||||
expect(userIdIsNotNull(sqlite)).toBe(false);
|
||||
|
||||
const row = sqlite.prepare("SELECT user_id, username FROM audit_logs").get() as {
|
||||
user_id: string | null;
|
||||
username: string;
|
||||
};
|
||||
expect(row.user_id).toBe("user-1");
|
||||
expect(row.username).toBe("alice");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a
|
||||
* number of tables that the primary bootstrap already creates. The probe never
|
||||
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and two
|
||||
* of those unreachable copies had drifted away from the real definition
|
||||
* (`sessions` had lost `ON DELETE CASCADE`; `session_recordings` still had the
|
||||
* pre-#1128 `user_id NOT NULL` with `ON DELETE CASCADE` and no `username`).
|
||||
*
|
||||
* They are gone now. What has to stay true is that the bootstrap alone
|
||||
* produces every one of those tables, from an empty database and from a
|
||||
* database that predates them.
|
||||
*/
|
||||
describe("bootstrap creates the tables the removed probes covered", () => {
|
||||
let dataDir: string;
|
||||
|
||||
// Exactly the tables whose unreachable re-creation was deleted.
|
||||
const TABLES = [
|
||||
"c2s_tunnel_presets",
|
||||
"sessions",
|
||||
"trusted_devices",
|
||||
"host_access",
|
||||
"roles",
|
||||
"user_roles",
|
||||
"audit_logs",
|
||||
"session_recordings",
|
||||
"api_keys",
|
||||
"session_shares",
|
||||
"session_share_participants",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-bootstrap-"));
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DB_FILE_ENCRYPTION = "false";
|
||||
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_FILE_ENCRYPTION;
|
||||
delete process.env.ALLOW_EMPTY_DATA_DIR;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function tablesIn(sqlite: Database.Database): Set<string> {
|
||||
const rows = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.pluck()
|
||||
.all() as string[];
|
||||
return new Set(rows);
|
||||
}
|
||||
|
||||
it("creates them all on a fresh database", async () => {
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
|
||||
const present = tablesIn(db.getSqlite());
|
||||
expect([...TABLES].filter((t) => !present.has(t))).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates them on a database old enough to predate them", async () => {
|
||||
// A database with users and hosts but none of the tables above — the
|
||||
// upgrade path the deleted probes appeared to be protecting.
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash');
|
||||
`);
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||
seed.close();
|
||||
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
const sqlite = db.getSqlite();
|
||||
|
||||
const present = tablesIn(sqlite);
|
||||
expect([...TABLES].filter((t) => !present.has(t))).toEqual([]);
|
||||
|
||||
// The row that was already there is still there: this is an upgrade, not
|
||||
// a rebuild.
|
||||
const count = sqlite
|
||||
.prepare("SELECT COUNT(*) FROM users")
|
||||
.pluck()
|
||||
.get() as number;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the definitions the stale copies disagreed with", async () => {
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
const sqlite = db.getSqlite();
|
||||
|
||||
// session_recordings: nullable user_id with the attribution kept, per
|
||||
// "audit trails survive the account" — not the NOT NULL + CASCADE the
|
||||
// dead copy still carried.
|
||||
const columns = sqlite
|
||||
.prepare("PRAGMA table_info(session_recordings)")
|
||||
.all() as Array<{ name: string; notnull: number }>;
|
||||
const userId = columns.find((c) => c.name === "user_id");
|
||||
expect(userId?.notnull).toBe(0);
|
||||
expect(columns.some((c) => c.name === "username")).toBe(true);
|
||||
|
||||
// sessions: the dead copy had dropped ON DELETE CASCADE.
|
||||
const sql = sqlite
|
||||
.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'sessions'",
|
||||
)
|
||||
.pluck()
|
||||
.get() as string;
|
||||
expect(sql.replace(/\s+/g, " ")).toContain("ON DELETE CASCADE");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { getTableName, is, Table } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as schema from "../../../database/db/schema.js";
|
||||
|
||||
/**
|
||||
* SQLite tables are created by hand-written DDL in `db/index.ts`, not generated
|
||||
* from the drizzle schema. Adding a table to `schema.ts` alone therefore
|
||||
* type-checks, passes the repository tests (which build their fixture straight
|
||||
* from the schema), and still fails at runtime with "no such table".
|
||||
*
|
||||
* That is exactly how the automations tables shipped broken, so this compares
|
||||
* the two directly: every table drizzle knows about has to exist after boot.
|
||||
*/
|
||||
describe("bootstrap creates every table in the drizzle schema", () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-schema-"));
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DB_FILE_ENCRYPTION = "false";
|
||||
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_FILE_ENCRYPTION;
|
||||
delete process.env.ALLOW_EMPTY_DATA_DIR;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("leaves no schema table missing from a fresh database", async () => {
|
||||
const expected = Object.values(schema)
|
||||
.filter((value) => is(value, Table))
|
||||
.map((table) => getTableName(table as Table))
|
||||
.sort();
|
||||
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
|
||||
const present = new Set(
|
||||
db
|
||||
.getSqlite()
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.pluck()
|
||||
.all() as string[],
|
||||
);
|
||||
|
||||
expect(expected.filter((name) => !present.has(name))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
assertUrlMatchesDialect,
|
||||
connectRemoteDatabase,
|
||||
databaseUrl,
|
||||
poolMax,
|
||||
sslOption,
|
||||
DATABASE_POOL_MAX_ENV,
|
||||
DATABASE_SSL_ENV,
|
||||
DATABASE_URL_ENV,
|
||||
} from "../../../database/db/connect.js";
|
||||
|
||||
@@ -61,7 +65,64 @@ describe("assertUrlMatchesDialect", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("poolMax", () => {
|
||||
it("defaults to the driver's own pool size", () => {
|
||||
expect(poolMax({})).toBe(10);
|
||||
expect(poolMax({ [DATABASE_POOL_MAX_ENV]: " " })).toBe(10);
|
||||
});
|
||||
|
||||
it("takes a positive integer", () => {
|
||||
expect(poolMax({ [DATABASE_POOL_MAX_ENV]: "25" })).toBe(25);
|
||||
});
|
||||
|
||||
it("refuses a value that would silently produce a broken pool", () => {
|
||||
for (const bad of ["0", "-1", "3.5", "lots"]) {
|
||||
expect(() => poolMax({ [DATABASE_POOL_MAX_ENV]: bad })).toThrow(
|
||||
/positive integer/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sslOption", () => {
|
||||
it("is off unless asked for, so existing installs are unaffected", () => {
|
||||
expect(sslOption({})).toBe(false);
|
||||
expect(sslOption({ [DATABASE_SSL_ENV]: "false" })).toBe(false);
|
||||
expect(sslOption({ [DATABASE_SSL_ENV]: "disable" })).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies the certificate for require", () => {
|
||||
expect(sslOption({ [DATABASE_SSL_ENV]: "require" })).toEqual({
|
||||
rejectUnauthorized: true,
|
||||
});
|
||||
expect(sslOption({ [DATABASE_SSL_ENV]: "TRUE" })).toEqual({
|
||||
rejectUnauthorized: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a self-signed certificate only when told to skip verification", () => {
|
||||
expect(sslOption({ [DATABASE_SSL_ENV]: "no-verify" })).toEqual({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a value it does not understand rather than quietly disabling TLS", () => {
|
||||
expect(() => sslOption({ [DATABASE_SSL_ENV]: "maybe" })).toThrow(
|
||||
/Unsupported DATABASE_SSL/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectRemoteDatabase", () => {
|
||||
it("validates pool settings before opening a connection", () => {
|
||||
return expect(
|
||||
connectRemoteDatabase("postgres", {
|
||||
[DATABASE_URL_ENV]: "postgres://db/termix",
|
||||
[DATABASE_POOL_MAX_ENV]: "nonsense",
|
||||
}),
|
||||
).rejects.toThrow(/positive integer/);
|
||||
});
|
||||
|
||||
it("refuses to connect without a URL, naming the variable", () => {
|
||||
return expect(connectRemoteDatabase("postgres", {})).rejects.toThrow(
|
||||
/DATABASE_URL must be set when DATABASE_DIALECT is "postgres"/,
|
||||
|
||||
@@ -10,6 +10,7 @@ import Database from "better-sqlite3";
|
||||
import * as sqliteSchema from "../../../database/db/schema.js";
|
||||
import * as pgSchema from "../../../database/db/schema.pg.js";
|
||||
import * as mysqlSchema from "../../../database/db/schema.mysql.js";
|
||||
import { PERFORMANCE_INDEXES } from "../../../database/db/performance-indexes.js";
|
||||
import {
|
||||
DATABASE_DIALECT_ENV,
|
||||
isDatabaseDialect,
|
||||
@@ -126,6 +127,40 @@ describe("generated schemas", () => {
|
||||
expect(schema.sshFolders.syncId.isUnique).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* SQLite builds its indexes at runtime from PERFORMANCE_INDEXES; Postgres and
|
||||
* MySQL only ever get what the migrations declare, which comes from schema.ts.
|
||||
* Anything listed in one and missing from the other is an index the engines
|
||||
* chosen for scale silently do without — which is how 31 of them went missing.
|
||||
*/
|
||||
it("declares every performance index in schema.ts", () => {
|
||||
// Both are the leading column of an existing composite unique index, which
|
||||
// already serves the same lookup. A second index would be redundant.
|
||||
const coveredByCompositePrefix = new Set([
|
||||
"idx_user_roles_user_id", // idx_user_roles_user_role (user_id, role_id)
|
||||
"idx_fleet_members_fleet", // idx_fleet_members_fleet_host (fleet_id, host_id)
|
||||
]);
|
||||
|
||||
const declared = new Set(
|
||||
Object.values(sqliteSchema)
|
||||
.filter((table): table is object => typeof table === "object")
|
||||
.flatMap((table) => {
|
||||
try {
|
||||
return sqliteTableConfig(table as never).indexes;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.map((index) => index.config.name),
|
||||
);
|
||||
|
||||
const missing = PERFORMANCE_INDEXES.map((index) => index.name)
|
||||
.filter((name) => !coveredByCompositePrefix.has(name))
|
||||
.filter((name) => !declared.has(name));
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import {
|
||||
PERFORMANCE_INDEXES,
|
||||
createPerformanceIndexes,
|
||||
} from "../../../database/db/performance-indexes.js";
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function seedSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
parent_host_id INTEGER,
|
||||
credential_id INTEGER
|
||||
);
|
||||
CREATE TABLE host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
expires_at TEXT
|
||||
);
|
||||
CREATE TABLE audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function indexNames(db: Database.Database): string[] {
|
||||
return db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'index'")
|
||||
.all()
|
||||
.map((row) => (row as { name: string }).name);
|
||||
}
|
||||
|
||||
describe("createPerformanceIndexes", () => {
|
||||
it("creates the indexes for tables that exist", () => {
|
||||
const db = new Database(":memory:");
|
||||
seedSchema(db);
|
||||
|
||||
const summary = createPerformanceIndexes(db, [
|
||||
{ name: "idx_ssh_data_user_id", table: "ssh_data", columns: "user_id" },
|
||||
{
|
||||
name: "idx_audit_logs_user_ts",
|
||||
table: "audit_logs",
|
||||
columns: "user_id, timestamp",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary).toMatchObject({ created: 2, skipped: 0, failed: 0 });
|
||||
expect(indexNames(db)).toEqual(
|
||||
expect.arrayContaining(["idx_ssh_data_user_id", "idx_audit_logs_user_ts"]),
|
||||
);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("is safe to run repeatedly", () => {
|
||||
const db = new Database(":memory:");
|
||||
seedSchema(db);
|
||||
|
||||
const first = createPerformanceIndexes(db);
|
||||
const second = createPerformanceIndexes(db);
|
||||
|
||||
expect(second.created).toBe(first.created);
|
||||
expect(second.failed).toBe(0);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("skips tables this install has not created instead of failing", () => {
|
||||
const db = new Database(":memory:");
|
||||
seedSchema(db);
|
||||
|
||||
const summary = createPerformanceIndexes(db, [
|
||||
{ name: "idx_missing", table: "not_a_table", columns: "user_id" },
|
||||
{ name: "idx_ssh_data_user_id", table: "ssh_data", columns: "user_id" },
|
||||
]);
|
||||
|
||||
expect(summary).toMatchObject({ created: 1, skipped: 1, failed: 0 });
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("skips columns an older schema has not added yet", () => {
|
||||
const db = new Database(":memory:");
|
||||
db.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT)");
|
||||
|
||||
const summary = createPerformanceIndexes(db, [
|
||||
{
|
||||
name: "idx_ssh_data_parent_host",
|
||||
table: "ssh_data",
|
||||
columns: "parent_host_id",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary).toMatchObject({ created: 0, skipped: 1, failed: 0 });
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("actually uses the index for the host list query", () => {
|
||||
const db = new Database(":memory:");
|
||||
seedSchema(db);
|
||||
createPerformanceIndexes(db);
|
||||
|
||||
const plan = db
|
||||
.prepare("EXPLAIN QUERY PLAN SELECT * FROM ssh_data WHERE user_id = ?")
|
||||
.all("user-1")
|
||||
.map((row) => (row as { detail: string }).detail)
|
||||
.join(" ");
|
||||
|
||||
expect(plan).toContain("idx_ssh_data_user_id");
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("uses a single index to satisfy the audit log filter and its ordering", () => {
|
||||
const db = new Database(":memory:");
|
||||
seedSchema(db);
|
||||
createPerformanceIndexes(db);
|
||||
|
||||
const plan = db
|
||||
.prepare(
|
||||
"EXPLAIN QUERY PLAN SELECT * FROM audit_logs WHERE user_id = ? ORDER BY timestamp DESC LIMIT 50",
|
||||
)
|
||||
.all("user-1")
|
||||
.map((row) => (row as { detail: string }).detail)
|
||||
.join(" ");
|
||||
|
||||
expect(plan).toContain("idx_audit_logs_user_ts");
|
||||
// Leading with the filtered column means the index also provides the order.
|
||||
expect(plan).not.toContain("TEMP B-TREE");
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("declares unique index names", () => {
|
||||
const names = PERFORMANCE_INDEXES.map((index) => index.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The Proxmox Stats feature adds `enable_proxmox_stats` and
|
||||
* `proxmox_stats_config` to `ssh_data`, backfilled via `addColumnIfNotExists`
|
||||
* next to the existing `enable_proxmox`/`proxmox_config` columns. Verify the
|
||||
* migration adds both columns, with the right default, on a database that
|
||||
* predates them.
|
||||
*/
|
||||
describe("proxmox stats columns migration", () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-proxmox-stats-"));
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DB_FILE_ENCRYPTION = "false";
|
||||
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_FILE_ENCRYPTION;
|
||||
delete process.env.ALLOW_EMPTY_DATA_DIR;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writePreUpgradeDatabase(): void {
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
auth_type TEXT NOT NULL DEFAULT 'password',
|
||||
enable_proxmox INTEGER NOT NULL DEFAULT 0,
|
||||
proxmox_config TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('owner', 'alice', 'hash');
|
||||
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type, enable_proxmox)
|
||||
VALUES (1, 'owner', 'pve node', '10.0.0.9', 22, 'root', 'password', 1);
|
||||
`);
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||
seed.close();
|
||||
}
|
||||
|
||||
it("adds enable_proxmox_stats (default 0) and proxmox_stats_config (nullable) columns", async () => {
|
||||
writePreUpgradeDatabase();
|
||||
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
const sqlite = db.getSqlite();
|
||||
|
||||
const columns = sqlite
|
||||
.prepare("PRAGMA table_info(ssh_data)")
|
||||
.all() as Array<{ name: string; notnull: number; dflt_value: string | null }>;
|
||||
|
||||
const enableCol = columns.find((c) => c.name === "enable_proxmox_stats");
|
||||
expect(enableCol).toBeDefined();
|
||||
expect(enableCol?.notnull).toBe(1);
|
||||
|
||||
const configCol = columns.find((c) => c.name === "proxmox_stats_config");
|
||||
expect(configCol).toBeDefined();
|
||||
expect(configCol?.notnull).toBe(0);
|
||||
|
||||
const row = sqlite
|
||||
.prepare(
|
||||
"SELECT enable_proxmox_stats, proxmox_stats_config FROM ssh_data WHERE id = 1",
|
||||
)
|
||||
.get() as { enable_proxmox_stats: number; proxmox_stats_config: string | null };
|
||||
|
||||
// Pre-existing rows default to disabled, independent of enable_proxmox.
|
||||
expect(row.enable_proxmox_stats).toBe(0);
|
||||
expect(row.proxmox_stats_config).toBeNull();
|
||||
});
|
||||
|
||||
it("creates the proxmox_node_history and proxmox_stats_preferences tables", async () => {
|
||||
writePreUpgradeDatabase();
|
||||
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
const sqlite = db.getSqlite();
|
||||
|
||||
const tables = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.pluck()
|
||||
.all() as string[];
|
||||
|
||||
expect(tables).toContain("proxmox_node_history");
|
||||
expect(tables).toContain("proxmox_stats_preferences");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Sharing a host used to hand the owner's SSH authentication to the recipient
|
||||
* unconditionally. 2.6.1 put that behind `ssh_data.share_ssh_auth`, added as
|
||||
* `NOT NULL DEFAULT 0` — so every host shared before the upgrade silently
|
||||
* stopped supplying credentials, and recipients hit "No valid authentication
|
||||
* method provided" on hosts that had worked the day before.
|
||||
*
|
||||
* Hosts that are already shared get the flag turned on, because that is where
|
||||
* the old behaviour was in effect. Hosts nobody has shared keep the new
|
||||
* default: their owner decides when they share one.
|
||||
*/
|
||||
describe("share_ssh_auth backfill", () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-share-auth-"));
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DB_FILE_ENCRYPTION = "false";
|
||||
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_FILE_ENCRYPTION;
|
||||
delete process.env.ALLOW_EMPTY_DATA_DIR;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A 2.6.0 database: hosts and shares exist, the column does not. */
|
||||
function writePreUpgradeDatabase(): void {
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL DEFAULT 'password',
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 0,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
default_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'use',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('owner', 'alice', 'hash'), ('recipient', 'bob', 'hash');
|
||||
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'owner', 'shared box', '10.0.0.7', 22, 'root', 'credential'),
|
||||
(2, 'owner', 'private box', '10.0.0.8', 22, 'root', 'credential');
|
||||
|
||||
INSERT INTO host_access (host_id, user_id, granted_by, permission_level)
|
||||
VALUES (1, 'recipient', 'owner', 'use');
|
||||
`);
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||
seed.close();
|
||||
}
|
||||
|
||||
function shareFlags(
|
||||
sqlite: Database.Database,
|
||||
): Record<number, number> {
|
||||
const rows = sqlite
|
||||
.prepare("SELECT id, share_ssh_auth FROM ssh_data ORDER BY id")
|
||||
.all() as Array<{ id: number; share_ssh_auth: number }>;
|
||||
return Object.fromEntries(rows.map((r) => [r.id, r.share_ssh_auth]));
|
||||
}
|
||||
|
||||
it("keeps already-shared hosts sharing their authentication", async () => {
|
||||
writePreUpgradeDatabase();
|
||||
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
|
||||
const flags = shareFlags(db.getSqlite());
|
||||
expect(flags[1]).toBe(1); // shared before the upgrade
|
||||
expect(flags[2]).toBe(0); // never shared, new default stands
|
||||
});
|
||||
|
||||
it("does not undo an owner who later turns it back off", async () => {
|
||||
writePreUpgradeDatabase();
|
||||
|
||||
const first = await import("../../../database/db/index.js");
|
||||
await first.initializeDatabase();
|
||||
first
|
||||
.getSqlite()
|
||||
.prepare("UPDATE ssh_data SET share_ssh_auth = 0 WHERE id = 1")
|
||||
.run();
|
||||
await first.saveMemoryDatabaseToFile?.();
|
||||
|
||||
// Second startup: the backfill is recorded as done and must not re-run.
|
||||
vi.resetModules();
|
||||
const second = await import("../../../database/db/index.js");
|
||||
await second.initializeDatabase();
|
||||
|
||||
expect(shareFlags(second.getSqlite())[1]).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* `ssh_credentials.username` became nullable when key-only credentials landed,
|
||||
* and databases created before that are rebuilt on startup to drop the
|
||||
* constraint — SQLite cannot ALTER a column.
|
||||
*
|
||||
* The rebuild restated the table's columns as a literal, then copied rows with
|
||||
* `INSERT INTO temp SELECT <every live column>`. The table has gained columns
|
||||
* since (cert_public_key, pin, sort_order, sync_id), so the literal was
|
||||
* narrower than the source: the INSERT failed on a column count mismatch, the
|
||||
* error was swallowed as a warning, and the constraint survived every restart.
|
||||
*
|
||||
* Deriving the replacement table from `sqlite_master` keeps the two in step by
|
||||
* construction. DROP TABLE also discards the table's indexes, so those are
|
||||
* replayed rather than left to whatever runs later.
|
||||
*/
|
||||
describe("ssh_credentials username rebuild", () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cred-rebuild-"));
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DB_FILE_ENCRYPTION = "false";
|
||||
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_FILE_ENCRYPTION;
|
||||
delete process.env.ALLOW_EMPTY_DATA_DIR;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* A database as a 2.6.x run leaves it: the old NOT NULL constraint is still
|
||||
* there, but the columns added since are present, as is the sync_id index.
|
||||
*/
|
||||
function writeDatabaseNeedingRebuild(): void {
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash');
|
||||
|
||||
CREATE TABLE ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
private_key TEXT,
|
||||
public_key TEXT,
|
||||
detected_key_type TEXT,
|
||||
cert_public_key TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER,
|
||||
sync_id TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_ssh_credentials_sync_id ON ssh_credentials(sync_id);
|
||||
`);
|
||||
seed
|
||||
.prepare(
|
||||
`INSERT INTO ssh_credentials (user_id, name, auth_type, username, pin, sort_order, sync_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run("user-1", "prod box", "password", "root", 1, 3, "sync-abc");
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||
seed.close();
|
||||
}
|
||||
|
||||
async function bootAndGetSqlite(): Promise<Database.Database> {
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
return db.getSqlite();
|
||||
}
|
||||
|
||||
function usernameIsNotNull(sqlite: Database.Database): boolean {
|
||||
const columns = sqlite.prepare("PRAGMA table_info(ssh_credentials)").all() as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
}>;
|
||||
return columns.find((col) => col.name === "username")?.notnull === 1;
|
||||
}
|
||||
|
||||
it("drops the constraint even though the table outgrew the old column list", async () => {
|
||||
writeDatabaseNeedingRebuild();
|
||||
|
||||
const sqlite = await bootAndGetSqlite();
|
||||
|
||||
expect(usernameIsNotNull(sqlite)).toBe(false);
|
||||
|
||||
expect(() =>
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO ssh_credentials (user_id, name, auth_type, key)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run("user-1", "key only", "key", "PRIVATE KEY"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("carries every column across, including the ones added after the rebuild was written", async () => {
|
||||
writeDatabaseNeedingRebuild();
|
||||
|
||||
const sqlite = await bootAndGetSqlite();
|
||||
|
||||
const row = sqlite.prepare("SELECT * FROM ssh_credentials WHERE name = ?").get("prod box") as {
|
||||
user_id: string;
|
||||
username: string;
|
||||
auth_type: string;
|
||||
pin: number;
|
||||
sort_order: number;
|
||||
sync_id: string;
|
||||
};
|
||||
|
||||
expect(row.user_id).toBe("user-1");
|
||||
expect(row.username).toBe("root");
|
||||
expect(row.auth_type).toBe("password");
|
||||
expect(row.pin).toBe(1);
|
||||
expect(row.sort_order).toBe(3);
|
||||
// sync_id identifies the row to remote sync; losing it re-keys the record.
|
||||
expect(row.sync_id).toBe("sync-abc");
|
||||
});
|
||||
|
||||
it("keeps the sync_id uniqueness that DROP TABLE would otherwise discard", async () => {
|
||||
writeDatabaseNeedingRebuild();
|
||||
|
||||
const sqlite = await bootAndGetSqlite();
|
||||
|
||||
const indexes = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'ssh_credentials'")
|
||||
.pluck()
|
||||
.all() as string[];
|
||||
expect(indexes).toContain("idx_ssh_credentials_sync_id");
|
||||
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_credentials (user_id, name, auth_type, sync_id) VALUES (?, ?, ?, ?)")
|
||||
.run("user-1", "other box", "key", "sync-xyz");
|
||||
|
||||
expect(() =>
|
||||
sqlite.prepare("UPDATE ssh_credentials SET sync_id = ? WHERE name = ?").run("sync-abc", "other box"),
|
||||
).toThrow(/UNIQUE/i);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { AlertRepository } from "../../../database/repositories/alert-repository.js";
|
||||
import { DataCrypto } from "../../../utils/data-crypto.js";
|
||||
|
||||
describe("AlertRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
@@ -226,6 +229,152 @@ describe("AlertRepository", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps another user's wildcard rules off a host they do not own", async () => {
|
||||
const repo = await createRepository();
|
||||
await adapter!.exec(`
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (2, 'user-2', 'bravo', '127.0.0.2', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
const ownerRule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "Owner wildcard",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const otherRule = await repo.createAlertRule({
|
||||
userId: "user-2",
|
||||
hostId: null,
|
||||
name: "Other wildcard",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
// Host 1 belongs to user-1, so only user-1's wildcard rule may fire.
|
||||
const forHostOne = await repo.listEnabledRulesForHost(1);
|
||||
expect(forHostOne.map((rule) => rule.id)).toEqual([ownerRule.id]);
|
||||
|
||||
const forHostTwo = await repo.listEnabledRulesForHost(2);
|
||||
expect(forHostTwo.map((rule) => rule.id)).toEqual([otherRule.id]);
|
||||
});
|
||||
|
||||
it("still matches a rule pinned to a specific host", async () => {
|
||||
const repo = await createRepository();
|
||||
const pinned = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
name: "Pinned",
|
||||
enabled: true,
|
||||
triggerType: "disk_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect((await repo.listEnabledRulesForHost(1)).map((r) => r.id)).toEqual([
|
||||
pinned.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("encrypts channel configs at rest and returns them decrypted", async () => {
|
||||
const key = crypto.randomBytes(32);
|
||||
const spy = vi
|
||||
.spyOn(DataCrypto, "getUserDataKey")
|
||||
.mockImplementation(() => key);
|
||||
|
||||
try {
|
||||
const repo = await createRepository();
|
||||
const secret = '{"url":"https://ntfy.test","token":"super-secret"}';
|
||||
|
||||
const created = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ntfy",
|
||||
type: "ntfy",
|
||||
config: secret,
|
||||
enabled: true,
|
||||
});
|
||||
expect(created.config).toBe(secret);
|
||||
|
||||
// The stored bytes must not contain the token in the clear.
|
||||
const stored = await adapter!.query<{ config: string }>(
|
||||
sql`SELECT config FROM notification_channels WHERE id = ${created.id}`,
|
||||
);
|
||||
expect(stored[0].config).not.toContain("super-secret");
|
||||
|
||||
// Every read path hands back plaintext.
|
||||
const listed = await repo.listNotificationChannels("user-1");
|
||||
expect(listed[0].config).toBe(secret);
|
||||
expect(
|
||||
(await repo.findNotificationChannelForUser(created.id, "user-1"))
|
||||
?.config,
|
||||
).toBe(secret);
|
||||
|
||||
const rule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [created.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const engineChannels = await repo.listEnabledChannelsForRule(rule.id);
|
||||
expect(engineChannels[0].config).toBe(secret);
|
||||
|
||||
const rotated = '{"url":"https://ntfy.test","token":"rotated"}';
|
||||
const updated = await repo.updateNotificationChannel(
|
||||
created.id,
|
||||
"user-1",
|
||||
{ config: rotated },
|
||||
);
|
||||
expect(updated?.config).toBe(rotated);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("still reads channel configs written before encryption", async () => {
|
||||
const repo = await createRepository();
|
||||
const plaintext = '{"url":"https://legacy.test"}';
|
||||
const created = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Legacy",
|
||||
type: "webhook",
|
||||
config: plaintext,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const key = crypto.randomBytes(32);
|
||||
const spy = vi
|
||||
.spyOn(DataCrypto, "getUserDataKey")
|
||||
.mockImplementation(() => key);
|
||||
try {
|
||||
const found = await repo.findNotificationChannelForUser(
|
||||
created.id,
|
||||
"user-1",
|
||||
);
|
||||
expect(found?.config).toBe(plaintext);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("loads host display names for alert payloads", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { AuditLogRepository } from "../../../database/repositories/audit-log-repository.js";
|
||||
|
||||
describe("AuditLogRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
AuditLogRepository.resetPruneThrottleForTests();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.AUDIT_LOG_MAX_ENTRIES;
|
||||
delete process.env.AUDIT_LOG_RETENTION_DAYS;
|
||||
AuditLogRepository.resetPruneThrottleForTests();
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
@@ -147,4 +155,122 @@ describe("AuditLogRepository", () => {
|
||||
|
||||
expect(await repo.anonymizeByUserId("user-2")).toBe(0);
|
||||
});
|
||||
|
||||
describe("pruning", () => {
|
||||
async function countEntries(): Promise<number> {
|
||||
const rows = await adapter!.query<{ count: number }>(
|
||||
sql`SELECT COUNT(*) AS count FROM audit_logs`,
|
||||
);
|
||||
return Number(rows[0].count);
|
||||
}
|
||||
|
||||
let writeSeq = 0;
|
||||
beforeEach(() => {
|
||||
writeSeq = 0;
|
||||
});
|
||||
|
||||
async function write(repo: AuditLogRepository, n: number): Promise<void> {
|
||||
for (let i = 0; i < n; i++) {
|
||||
await repo.create({
|
||||
userId: "user-1",
|
||||
username: "alice",
|
||||
action: "host_connect",
|
||||
resourceType: "host",
|
||||
success: true,
|
||||
// Monotonic across calls so a second batch never reuses timestamps
|
||||
// from the first, which would make "oldest first" ambiguous.
|
||||
timestamp: new Date(
|
||||
Date.UTC(2026, 0, 1, 0, 0, writeSeq++),
|
||||
).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("enforces the entry cap down to the target ratio", async () => {
|
||||
process.env.AUDIT_LOG_MAX_ENTRIES = "10";
|
||||
const repo = await createRepository();
|
||||
|
||||
await write(repo, 10);
|
||||
await repo.pruneNow();
|
||||
|
||||
// Cap 10, target ratio 0.9 -> trimmed back to 9.
|
||||
expect(await countEntries()).toBe(9);
|
||||
});
|
||||
|
||||
it("drops the oldest entries first when over the cap", async () => {
|
||||
process.env.AUDIT_LOG_MAX_ENTRIES = "10";
|
||||
const repo = await createRepository();
|
||||
|
||||
await write(repo, 10);
|
||||
await repo.pruneNow();
|
||||
|
||||
const rows = await adapter!.query<{ timestamp: string }>(
|
||||
sql`SELECT timestamp FROM audit_logs ORDER BY timestamp ASC`,
|
||||
);
|
||||
// The first-written entry is the one discarded.
|
||||
expect(rows[0].timestamp).toBe(
|
||||
new Date(Date.UTC(2026, 0, 1, 0, 0, 1)).toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes entries past the retention window", async () => {
|
||||
process.env.AUDIT_LOG_RETENTION_DAYS = "1";
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
username: "alice",
|
||||
action: "old",
|
||||
resourceType: "host",
|
||||
success: true,
|
||||
timestamp: new Date(Date.now() - 5 * 86_400_000).toISOString(),
|
||||
});
|
||||
await repo.create({
|
||||
username: "alice",
|
||||
action: "fresh",
|
||||
resourceType: "host",
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await repo.pruneNow();
|
||||
|
||||
const rows = await adapter!.query<{ action: string }>(
|
||||
sql`SELECT action FROM audit_logs`,
|
||||
);
|
||||
expect(rows.map((row) => row.action)).toEqual(["fresh"]);
|
||||
});
|
||||
|
||||
it("keeps enforcing the cap across a burst of writes", async () => {
|
||||
process.env.AUDIT_LOG_MAX_ENTRIES = "10";
|
||||
const repo = await createRepository();
|
||||
|
||||
// The cap is checked on the write that crosses it, not on a timer, so a
|
||||
// burst cannot run the table away past the ceiling.
|
||||
await write(repo, 24);
|
||||
|
||||
expect(await countEntries()).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("re-reads the row count after entries are deleted elsewhere", async () => {
|
||||
process.env.AUDIT_LOG_MAX_ENTRIES = "10";
|
||||
const repo = await createRepository();
|
||||
|
||||
await write(repo, 9);
|
||||
// Clears the table, so the cached count is now far too high.
|
||||
await repo.deleteByUserId("user-1");
|
||||
await write(repo, 9);
|
||||
|
||||
// Had the count kept counting up from 9, this second batch would have
|
||||
// tripped the cap and pruned; a correct re-read leaves all 9 in place.
|
||||
expect(await countEntries()).toBe(9);
|
||||
});
|
||||
|
||||
it("keeps the write succeeding even when pruning cannot run", async () => {
|
||||
process.env.AUDIT_LOG_MAX_ENTRIES = "not-a-number";
|
||||
const repo = await createRepository();
|
||||
|
||||
await expect(write(repo, 1)).resolves.toBeUndefined();
|
||||
expect(await countEntries()).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { CredentialSidebarPreferenceRepository } from "../../../database/repositories/credential-sidebar-preference-repository.js";
|
||||
|
||||
describe("CredentialSidebarPreferenceRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<CredentialSidebarPreferenceRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO credential_sidebar_preferences (user_id, data, updated_at)
|
||||
VALUES (
|
||||
'user-1',
|
||||
'{"version":1,"sort":{"key":"default","pinnedFirst":false}}',
|
||||
'2026-01-01T00:00:00.000Z'
|
||||
);
|
||||
`);
|
||||
|
||||
return new CredentialSidebarPreferenceRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds a saved preferences row by user id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const existing = await repo.findByUserId("user-1");
|
||||
expect(existing?.data).toBe(
|
||||
'{"version":1,"sort":{"key":"default","pinnedFirst":false}}',
|
||||
);
|
||||
expect(await repo.findByUserId("user-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("updates and inserts preferences with write notifications", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const updated = await repo.upsert(
|
||||
"user-1",
|
||||
'{"version":1,"display":{"density":"compact"}}',
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
userId: "user-1",
|
||||
data: '{"version":1,"display":{"density":"compact"}}',
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const created = await repo.upsert(
|
||||
"user-2",
|
||||
'{"version":1,"sort":{"key":"manual"}}',
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-2",
|
||||
data: '{"version":1,"sort":{"key":"manual"}}',
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes preferences for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsert("user-2", '{"version":1}');
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.findByUserId("user-1")).toBeNull();
|
||||
expect((await repo.findByUserId("user-2"))?.data).toBe('{"version":1}');
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,14 @@ import { DATABASE_DIALECT_ENV } from "../../../database/db/dialect.js";
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => ({}),
|
||||
getSqlite: () => ({}),
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn() },
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn(), triggerSave: vi.fn() },
|
||||
}));
|
||||
|
||||
const { createCurrentRepositoryContext, createCurrentRepositoryWriteHook } =
|
||||
await import("../../../database/repositories/factory.js");
|
||||
const {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
createCurrentRepositoryLazyWriteHook,
|
||||
} = await import("../../../database/repositories/factory.js");
|
||||
|
||||
// Neither cross-dialect harness reaches this function: both
|
||||
// tests/database/repositories/test-support.ts and scripts/verify-dialects.mjs
|
||||
@@ -64,3 +67,22 @@ describe("createCurrentRepositoryWriteHook", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCurrentRepositoryLazyWriteHook", () => {
|
||||
const saved = process.env[DATABASE_DIALECT_ENV];
|
||||
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV];
|
||||
else process.env[DATABASE_DIALECT_ENV] = saved;
|
||||
});
|
||||
|
||||
it("installs a debounced persist hook only for sqlite", () => {
|
||||
process.env[DATABASE_DIALECT_ENV] = "sqlite";
|
||||
expect(createCurrentRepositoryLazyWriteHook("test")).toBeTypeOf("function");
|
||||
|
||||
for (const dialect of ["postgres", "mysql"]) {
|
||||
process.env[DATABASE_DIALECT_ENV] = dialect;
|
||||
expect(createCurrentRepositoryLazyWriteHook("test")).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { FleetRepository } from "../../../database/repositories/fleet-repository.js";
|
||||
|
||||
describe("FleetRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<{ repository: FleetRepository }> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type, tags)
|
||||
VALUES
|
||||
(1, 'user-1', 'web-1', '10.0.0.1', 22, 'root', 'password', 'prod-web,edge'),
|
||||
(2, 'user-1', 'web-2', '10.0.0.2', 22, 'root', 'password', 'prod-web'),
|
||||
(3, 'user-1', 'db-1', '10.0.0.3', 22, 'root', 'password', 'prod-db'),
|
||||
(4, 'user-1', 'static-only', '10.0.0.4', 22, 'root', 'password', NULL),
|
||||
(5, 'user-2', 'other-user-host', '10.0.0.5', 22, 'root', 'password', 'prod-web');
|
||||
`);
|
||||
|
||||
return { repository: new FleetRepository(context, onWrite) };
|
||||
}
|
||||
|
||||
it("unions static membership and tag-matched hosts, deduplicated by id", async () => {
|
||||
let writes = 0;
|
||||
const { repository } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const fleet = await repository.create("user-1", {
|
||||
name: "web fleet",
|
||||
tagRules: ["prod-web"],
|
||||
});
|
||||
// host 1 matches by tag AND is added statically - must appear once.
|
||||
await repository.addMember(fleet.id, 1);
|
||||
// host 4 has no tags - only reachable via static membership.
|
||||
await repository.addMember(fleet.id, 4);
|
||||
|
||||
const members = await repository.listEffectiveMembers("user-1", fleet.id);
|
||||
const ids = members.map((m) => m.id).sort((a, b) => a - b);
|
||||
|
||||
expect(ids).toEqual([1, 2, 4]);
|
||||
expect(writes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never returns another user's hosts even if tags match", async () => {
|
||||
const { repository } = await createRepository();
|
||||
|
||||
const fleet = await repository.create("user-1", {
|
||||
name: "web fleet",
|
||||
tagRules: ["prod-web"],
|
||||
});
|
||||
|
||||
const members = await repository.listEffectiveMembers("user-1", fleet.id);
|
||||
expect(members.map((m) => m.id)).not.toContain(5);
|
||||
});
|
||||
|
||||
it("returns only static members when a fleet has no tag rules", async () => {
|
||||
const { repository } = await createRepository();
|
||||
|
||||
const fleet = await repository.create("user-1", { name: "static fleet" });
|
||||
await repository.addMember(fleet.id, 3);
|
||||
|
||||
const members = await repository.listEffectiveMembers("user-1", fleet.id);
|
||||
expect(members.map((m) => m.id)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("removeMember only drops the static row, not tag-based membership", async () => {
|
||||
const { repository } = await createRepository();
|
||||
|
||||
const fleet = await repository.create("user-1", {
|
||||
name: "web fleet",
|
||||
tagRules: ["prod-web"],
|
||||
});
|
||||
await repository.addMember(fleet.id, 1);
|
||||
|
||||
await expect(repository.removeMember(fleet.id, 1)).resolves.toBe(true);
|
||||
|
||||
// host 1 still matches the tag rule, so it remains an effective member.
|
||||
const members = await repository.listEffectiveMembers("user-1", fleet.id);
|
||||
expect(members.map((m) => m.id)).toContain(1);
|
||||
});
|
||||
|
||||
it("returns an empty list for a fleet the caller does not own", async () => {
|
||||
const { repository } = await createRepository();
|
||||
const fleet = await repository.create("user-1", { name: "private" });
|
||||
|
||||
await expect(
|
||||
repository.listEffectiveMembers("user-2", fleet.id),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -561,6 +561,67 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
expect(updated?.lastUsed).toBe("2026-06-26T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("sets a distinct sortOrder per credential via reorderForUser", async () => {
|
||||
const onWrite = vi.fn();
|
||||
const repo = await createRepositories(onWrite);
|
||||
|
||||
const first = await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "one",
|
||||
authType: "password",
|
||||
});
|
||||
const second = await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "two",
|
||||
authType: "password",
|
||||
});
|
||||
onWrite.mockClear();
|
||||
|
||||
const updated = await repo.credentials.reorderForUser("user-1", [
|
||||
{ id: first.id, sortOrder: 2000 },
|
||||
{ id: second.id, sortOrder: 1000 },
|
||||
]);
|
||||
expect(updated).toBe(2);
|
||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
await adapter!.query(
|
||||
sql`SELECT id, sort_order FROM ssh_credentials WHERE user_id = 'user-1' ORDER BY id`,
|
||||
),
|
||||
).toEqual([
|
||||
{ id: first.id, sort_order: 2000 },
|
||||
{ id: second.id, sort_order: 1000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores credential ids the user does not own when reordering", async () => {
|
||||
const repo = await createRepositories();
|
||||
|
||||
const other = await repo.credentials.create({
|
||||
userId: "user-2",
|
||||
name: "other",
|
||||
authType: "password",
|
||||
});
|
||||
|
||||
const updated = await repo.credentials.reorderForUser("user-1", [
|
||||
{ id: other.id, sortOrder: 5000 },
|
||||
]);
|
||||
expect(updated).toBe(0);
|
||||
|
||||
expect(
|
||||
await adapter!.query(
|
||||
sql`SELECT sort_order FROM ssh_credentials WHERE id = ${other.id}`,
|
||||
),
|
||||
).toEqual([{ sort_order: null }]);
|
||||
});
|
||||
|
||||
it("no-ops reorderForUser on an empty positions array", async () => {
|
||||
const repo = await createRepositories();
|
||||
await expect(repo.credentials.reorderForUser("user-1", [])).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans host access before deleting a host", async () => {
|
||||
const repo = await createRepositories();
|
||||
const host = await repo.hosts.create({
|
||||
|
||||
@@ -170,4 +170,53 @@ describe("HostFolderRepository", () => {
|
||||
).toEqual([{ id: 3 }]);
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("sets sortOrder on existing folder rows", async () => {
|
||||
let writes = 0;
|
||||
const { repository } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const updated = await repository.reorderFolders(
|
||||
"user-1",
|
||||
[
|
||||
{ name: "prod", sortOrder: 2000 },
|
||||
{ name: "prod / api", sortOrder: 1000 },
|
||||
],
|
||||
"2026-04-01T00:00:00.000Z",
|
||||
);
|
||||
expect(updated).toBe(2);
|
||||
expect(writes).toBe(1);
|
||||
|
||||
expect(
|
||||
await adapter!.query(
|
||||
sql`SELECT name, sort_order FROM ssh_folders WHERE user_id = 'user-1' ORDER BY id`,
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "prod", sort_order: 2000 },
|
||||
{ name: "prod / api", sort_order: 1000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates a folder row when reordering a folder with no existing metadata", async () => {
|
||||
const { repository } = await createRepository();
|
||||
|
||||
const updated = await repository.reorderFolders(
|
||||
"user-1",
|
||||
[{ name: "implicit-folder", sortOrder: 500 }],
|
||||
"2026-04-01T00:00:00.000Z",
|
||||
);
|
||||
expect(updated).toBe(1);
|
||||
|
||||
expect(
|
||||
await adapter!.query(
|
||||
sql`SELECT name, sort_order FROM ssh_folders WHERE name = 'implicit-folder'`,
|
||||
),
|
||||
).toEqual([{ name: "implicit-folder", sort_order: 500 }]);
|
||||
});
|
||||
|
||||
it("no-ops on an empty positions array", async () => {
|
||||
const { repository } = await createRepository();
|
||||
await expect(repository.reorderFolders("user-1", [])).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { HostRepository } from "../../../database/repositories/host-repository.js";
|
||||
|
||||
describe("HostRepository.reorderForUser", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HostRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES
|
||||
(1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'),
|
||||
(2, 'user-1', 'two', '10.0.0.2', 22, 'root', 'password'),
|
||||
(3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new HostRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("sets a distinct sortOrder per host", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const updated = await repo.reorderForUser("user-1", [
|
||||
{ id: 1, sortOrder: 2000 },
|
||||
{ id: 2, sortOrder: 1000 },
|
||||
]);
|
||||
expect(updated).toBe(2);
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
expect(
|
||||
await adapter!.query(
|
||||
sql`SELECT id, sort_order FROM ssh_data WHERE user_id = 'user-1' ORDER BY id`,
|
||||
),
|
||||
).toEqual([
|
||||
{ id: 1, sort_order: 2000 },
|
||||
{ id: 2, sort_order: 1000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores ids the user does not own", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const updated = await repo.reorderForUser("user-1", [
|
||||
{ id: 3, sortOrder: 5000 },
|
||||
]);
|
||||
expect(updated).toBe(0);
|
||||
|
||||
expect(
|
||||
await adapter!.query(sql`SELECT sort_order FROM ssh_data WHERE id = 3`),
|
||||
).toEqual([{ sort_order: null }]);
|
||||
});
|
||||
|
||||
it("no-ops on an empty positions array", async () => {
|
||||
const repo = await createRepository();
|
||||
await expect(repo.reorderForUser("user-1", [])).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -197,6 +197,8 @@ describe("HostResolutionRepository", () => {
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: null,
|
||||
authType: "password",
|
||||
parentHostId: null,
|
||||
folder: null,
|
||||
});
|
||||
await expect(repository.findHostUpdateState(999)).resolves.toBeNull();
|
||||
expect(DataCrypto.decryptRecord).not.toHaveBeenCalled();
|
||||
@@ -374,4 +376,72 @@ describe("HostResolutionRepository", () => {
|
||||
repository.findFolderCredentialId("user-1", ""),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
describe("listCredentialsByIdsForUser", () => {
|
||||
it("returns the owner's credentials keyed by id", async () => {
|
||||
const repository = await createRepository();
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(
|
||||
Buffer.from("key") as never,
|
||||
);
|
||||
|
||||
const byId = await repository.listCredentialsByIdsForUser([7], "user-1");
|
||||
|
||||
expect(byId.get(7)).toMatchObject({ id: 7, username: "root" });
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_credentials",
|
||||
expect.objectContaining({ id: 7 }),
|
||||
"user-1",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes credentials belonging to another user", async () => {
|
||||
const repository = await createRepository();
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(
|
||||
Buffer.from("key") as never,
|
||||
);
|
||||
|
||||
const byId = await repository.listCredentialsByIdsForUser(
|
||||
[7, 8],
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(byId.has(7)).toBe(true);
|
||||
// 8 belongs to user-2 and must not leak into user-1's result.
|
||||
expect(byId.has(8)).toBe(false);
|
||||
});
|
||||
|
||||
it("issues no query and decrypts nothing for an empty id list", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
const byId = await repository.listCredentialsByIdsForUser([], "user-1");
|
||||
|
||||
expect(byId.size).toBe(0);
|
||||
expect(DataCrypto.decryptRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("de-duplicates repeated ids so shared credentials decrypt once", async () => {
|
||||
const repository = await createRepository();
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(
|
||||
Buffer.from("key") as never,
|
||||
);
|
||||
|
||||
const byId = await repository.listCredentialsByIdsForUser(
|
||||
[7, 7, 7],
|
||||
"user-1",
|
||||
);
|
||||
|
||||
expect(byId.size).toBe(1);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns nothing when the user's data key is unavailable", async () => {
|
||||
const repository = await createRepository();
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(null as never);
|
||||
|
||||
const byId = await repository.listCredentialsByIdsForUser([7], "user-1");
|
||||
|
||||
expect(byId.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { HostSidebarPreferenceRepository } from "../../../database/repositories/host-sidebar-preference-repository.js";
|
||||
|
||||
describe("HostSidebarPreferenceRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HostSidebarPreferenceRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO host_sidebar_preferences (user_id, data, updated_at)
|
||||
VALUES (
|
||||
'user-1',
|
||||
'{"version":1,"sort":{"key":"default","pinnedFirst":false}}',
|
||||
'2026-01-01T00:00:00.000Z'
|
||||
);
|
||||
`);
|
||||
|
||||
return new HostSidebarPreferenceRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds a saved preferences row by user id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const existing = await repo.findByUserId("user-1");
|
||||
expect(existing?.data).toBe(
|
||||
'{"version":1,"sort":{"key":"default","pinnedFirst":false}}',
|
||||
);
|
||||
expect(await repo.findByUserId("user-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("updates and inserts preferences with write notifications", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const updated = await repo.upsert(
|
||||
"user-1",
|
||||
'{"version":1,"groupKey":"tag"}',
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
userId: "user-1",
|
||||
data: '{"version":1,"groupKey":"tag"}',
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const created = await repo.upsert(
|
||||
"user-2",
|
||||
'{"version":1,"groupKey":"folder"}',
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-2",
|
||||
data: '{"version":1,"groupKey":"folder"}',
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes preferences for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsert("user-2", '{"version":1}');
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.findByUserId("user-1")).toBeNull();
|
||||
expect((await repo.findByUserId("user-2"))?.data).toBe('{"version":1}');
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { ProxmoxNodeHistoryRepository } from "../../../database/repositories/proxmox-node-history-repository.js";
|
||||
|
||||
describe("ProxmoxNodeHistoryRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<ProxmoxNodeHistoryRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'user-1', 'hash'),
|
||||
('user-2', 'user-2', 'hash');
|
||||
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'pve1', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'pve2', '10.0.0.2', 22, 'root', 'password');
|
||||
INSERT INTO proxmox_node_history (
|
||||
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
|
||||
)
|
||||
VALUES
|
||||
(1, '2026-01-01 00:00:00', 10, 20, 30, 100, 200),
|
||||
(1, '2026-01-02 00:00:00', 11, 21, 31, 101, 201),
|
||||
(1, '2999-01-01 00:00:00', 12, 22, 32, 102, 202),
|
||||
(2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999);
|
||||
`);
|
||||
|
||||
return new ProxmoxNodeHistoryRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists node history rows by range", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
hostId: 1,
|
||||
cpuPercent: 12,
|
||||
memPercent: 22,
|
||||
diskPercent: 32,
|
||||
netRxBytes: 102,
|
||||
netTxBytes: 202,
|
||||
});
|
||||
|
||||
const rows = await repo.listRange(
|
||||
1,
|
||||
"2026-01-01 00:00:00",
|
||||
"2026-01-02 23:59:59",
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]);
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("prunes old history for a host only", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.pruneOlderThan(1, 1);
|
||||
|
||||
const rows = await repo.listRange(
|
||||
1,
|
||||
"2000-01-01 00:00:00",
|
||||
"2999-12-31 23:59:59",
|
||||
);
|
||||
expect(rows.map((row) => row.ts)).toEqual(["2999-01-01 00:00:00"]);
|
||||
expect(
|
||||
await repo.listRange(2, "2026-01-01 00:00:00", "2026-01-03 00:00:00"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -352,11 +352,20 @@ async function resyncAutoIncrement(
|
||||
): Promise<void> {
|
||||
for (const table of tables) {
|
||||
// Only tables whose id is generated. A text primary key, like users.id,
|
||||
// has no sequence and no counter to move.
|
||||
// has no sequence and no counter to move, and a table keyed on something
|
||||
// else entirely — host_sidebar_preferences.user_id — has no id at all.
|
||||
if (context.dialect === "postgres") {
|
||||
// pg_get_serial_sequence() raises 42703 rather than returning null when
|
||||
// the column is missing, so let information_schema decide whether there
|
||||
// is an id to ask about: no id column yields no row.
|
||||
const [seq] = await runSql<{ name: string | null }>(
|
||||
context,
|
||||
sql.raw(`SELECT pg_get_serial_sequence('${table}', 'id') AS name`),
|
||||
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 (!seq?.name) continue;
|
||||
|
||||
@@ -423,29 +432,39 @@ function migrateOnce(
|
||||
let cachedSqliteSchema: string | null = null;
|
||||
|
||||
/**
|
||||
* The full schema, from the generated SQLite migration rather than hand-written
|
||||
* DDL in each test file.
|
||||
* The full schema, from the generated SQLite migrations rather than
|
||||
* hand-written DDL in each test file.
|
||||
*
|
||||
* Tests used to declare a cut-down version of every table they touched — a
|
||||
* `users` with five columns where the real one has thirty. That drifts from the
|
||||
* schema silently, and it is the reason the same tests could not be pointed at
|
||||
* another engine.
|
||||
*
|
||||
* There can be more than one migration file (a baseline plus later
|
||||
* incremental ones): drizzle-kit numbers them `0000_`, `0001_`, ... in
|
||||
* generation order, so replaying every file in that (lexical) order
|
||||
* reconstructs the current schema exactly like a real migration run would.
|
||||
*/
|
||||
function sqliteSchemaSql(): string {
|
||||
if (cachedSqliteSchema) return cachedSqliteSchema;
|
||||
|
||||
const dir = path.resolve(process.cwd(), "drizzle", "sqlite");
|
||||
const file = fs
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort()
|
||||
.at(-1);
|
||||
.sort();
|
||||
|
||||
if (!file) throw new Error(`No SQLite migration found in ${dir}`);
|
||||
if (files.length === 0) {
|
||||
throw new Error(`No SQLite migration found in ${dir}`);
|
||||
}
|
||||
|
||||
cachedSqliteSchema = fs
|
||||
.readFileSync(path.join(dir, file), "utf8")
|
||||
.split("--> statement-breakpoint")
|
||||
cachedSqliteSchema = files
|
||||
.map((file) =>
|
||||
fs
|
||||
.readFileSync(path.join(dir, file), "utf8")
|
||||
.split("--> statement-breakpoint")
|
||||
.join("\n"),
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return cachedSqliteSchema;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { UiPreferenceRepository } from "../../../database/repositories/ui-preference-repository.js";
|
||||
|
||||
describe("UiPreferenceRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<UiPreferenceRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ui_preferences (user_id, data, updated_at)
|
||||
VALUES (
|
||||
'user-1',
|
||||
'{"version":1,"preset":"balanced","overrides":{}}',
|
||||
'2026-01-01T00:00:00.000Z'
|
||||
);
|
||||
`);
|
||||
return new UiPreferenceRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds saved UI preferences by user id", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
const existing = await repository.findByUserId("user-1");
|
||||
expect(existing?.data).toBe(
|
||||
'{"version":1,"preset":"balanced","overrides":{}}',
|
||||
);
|
||||
|
||||
expect(await repository.findByUserId("user-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("updates and inserts preferences with write notifications", async () => {
|
||||
let writeCount = 0;
|
||||
const repository = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const updated = await repository.upsert(
|
||||
"user-1",
|
||||
'{"version":1,"preset":"simple","overrides":{}}',
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
userId: "user-1",
|
||||
data: '{"version":1,"preset":"simple","overrides":{}}',
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const created = await repository.upsert(
|
||||
"user-2",
|
||||
'{"version":1,"preset":"advanced","overrides":{}}',
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-2",
|
||||
data: '{"version":1,"preset":"advanced","overrides":{}}',
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes preferences for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repository = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repository.upsert("user-2", '{"version":1,"preset":"simple"}');
|
||||
|
||||
await expect(repository.deleteByUserId("user-1")).resolves.toBe(1);
|
||||
await expect(repository.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repository.findByUserId("user-1")).toBeNull();
|
||||
expect((await repository.findByUserId("user-2"))?.data).toBe(
|
||||
'{"version":1,"preset":"simple"}',
|
||||
);
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { UserRepository } from "../../../database/repositories/user-repository.js";
|
||||
|
||||
describe("UserRepository.listPage", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(): Promise<UserRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('u1', 'alice', 'hash'),
|
||||
('u2', 'Bob', 'hash'),
|
||||
('u3', 'carol', 'hash'),
|
||||
('u4', 'dave', 'hash'),
|
||||
('u5', 'alicia', 'hash');
|
||||
`);
|
||||
|
||||
return new UserRepository(context);
|
||||
}
|
||||
|
||||
it("returns a page ordered by username with the full total", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const page = await repo.listPage({ limit: 2, offset: 0 });
|
||||
|
||||
expect(page.users.map((u) => u.username)).toEqual(["alice", "alicia"]);
|
||||
// total counts every match, not just the returned page.
|
||||
expect(page.total).toBe(5);
|
||||
});
|
||||
|
||||
it("pages forward without repeating or skipping a row", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const first = await repo.listPage({ limit: 2, offset: 0 });
|
||||
const second = await repo.listPage({ limit: 2, offset: 2 });
|
||||
const third = await repo.listPage({ limit: 2, offset: 4 });
|
||||
|
||||
expect(
|
||||
[...first.users, ...second.users, ...third.users].map((u) => u.username),
|
||||
).toEqual(["alice", "alicia", "Bob", "carol", "dave"]);
|
||||
});
|
||||
|
||||
it("filters by username case-insensitively", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const page = await repo.listPage({ search: "ALI", limit: 10, offset: 0 });
|
||||
|
||||
expect(page.users.map((u) => u.username)).toEqual(["alice", "alicia"]);
|
||||
expect(page.total).toBe(2);
|
||||
});
|
||||
|
||||
it("counts only matching rows when searching", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const page = await repo.listPage({ search: "ali", limit: 1, offset: 0 });
|
||||
|
||||
expect(page.users).toHaveLength(1);
|
||||
expect(page.total).toBe(2);
|
||||
});
|
||||
|
||||
it("returns an empty page for a term nobody matches", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const page = await repo.listPage({ search: "zzz", limit: 10, offset: 0 });
|
||||
|
||||
expect(page.users).toEqual([]);
|
||||
expect(page.total).toBe(0);
|
||||
});
|
||||
|
||||
it("treats a blank search as no filter", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const page = await repo.listPage({ search: " ", limit: 10, offset: 0 });
|
||||
|
||||
expect(page.total).toBe(5);
|
||||
});
|
||||
|
||||
it("returns an empty page past the end of the results", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const page = await repo.listPage({ limit: 10, offset: 99 });
|
||||
|
||||
expect(page.users).toEqual([]);
|
||||
expect(page.total).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -193,6 +193,44 @@ describe("UserRepository and SessionRepository", () => {
|
||||
expect(await repo.sessions.findById("session-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("persists session activity at most once per minute", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepositories({
|
||||
onSessionWrite: () => {
|
||||
writeCount += 1;
|
||||
},
|
||||
});
|
||||
await repo.users.create({
|
||||
id: "user-1",
|
||||
username: "user",
|
||||
passwordHash: "hash",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
});
|
||||
await repo.sessions.create({
|
||||
id: "session-1",
|
||||
userId: "user-1",
|
||||
jwtToken: "token",
|
||||
deviceType: "desktop",
|
||||
deviceInfo: "Firefox",
|
||||
createdAt: "2026-06-26T00:00:00.000Z",
|
||||
expiresAt: "2026-06-27T00:00:00.000Z",
|
||||
lastActiveAt: "2026-06-26T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.sessions.touch("session-1", "2026-06-26T00:00:30.000Z"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await repo.sessions.touch("session-1", "2026-06-26T00:01:00.000Z"),
|
||||
).toBe(true);
|
||||
|
||||
expect(writeCount).toBe(2);
|
||||
expect((await repo.sessions.findById("session-1"))?.lastActiveAt).toBe(
|
||||
"2026-06-26T00:01:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("revokes all user sessions except an optional current session", async () => {
|
||||
const repo = await createRepositories();
|
||||
await repo.users.create({
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { WorkspaceRepository } from "../../../database/repositories/workspace-repository.js";
|
||||
|
||||
describe("WorkspaceRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<WorkspaceRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new WorkspaceRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists manual workspaces scoped to the owning user", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create("user-1", { name: "Prod Debugging", payload: "{}" });
|
||||
await repo.create("user-2", { name: "Other user's", payload: "{}" });
|
||||
|
||||
const list = await repo.listByUser("user-1");
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
name: "Prod Debugging",
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
});
|
||||
expect(list[0].syncId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("finds a workspace by id scoped to the owner", async () => {
|
||||
const repo = await createRepository();
|
||||
const created = await repo.create("user-1", {
|
||||
name: "Test A",
|
||||
payload: "{}",
|
||||
});
|
||||
|
||||
expect(await repo.findById("user-1", created.id)).toMatchObject({
|
||||
id: created.id,
|
||||
});
|
||||
expect(await repo.findById("user-2", created.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("upsertLastSession creates then updates a single row, never a second one", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const first = await repo.upsertLastSession("user-1", '{"tabs":[]}');
|
||||
expect(first.kind).toBe("last_session");
|
||||
|
||||
const second = await repo.upsertLastSession(
|
||||
"user-1",
|
||||
'{"tabs":[{"slotId":"a"}]}',
|
||||
);
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.payload).toBe('{"tabs":[{"slotId":"a"}]}');
|
||||
|
||||
const all = await repo.listByUser("user-1");
|
||||
expect(all.filter((w) => w.kind === "last_session")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("update renames/recolors a manual workspace but rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const manual = await repo.create("user-1", {
|
||||
name: "Old Name",
|
||||
payload: "{}",
|
||||
});
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
const updated = await repo.update("user-1", manual.id, {
|
||||
name: "New Name",
|
||||
color: "#fff",
|
||||
});
|
||||
expect(updated).toMatchObject({ name: "New Name", color: "#fff" });
|
||||
|
||||
const rejected = await repo.update("user-1", lastSession.id, {
|
||||
name: "Should not work",
|
||||
});
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("updateContent overwrites payload for a manual workspace but rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const manual = await repo.create("user-1", {
|
||||
name: "Test A",
|
||||
payload: "{}",
|
||||
});
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
const updated = await repo.updateContent(
|
||||
"user-1",
|
||||
manual.id,
|
||||
'{"tabs":[1]}',
|
||||
);
|
||||
expect(updated?.payload).toBe('{"tabs":[1]}');
|
||||
|
||||
const rejected = await repo.updateContent(
|
||||
"user-1",
|
||||
lastSession.id,
|
||||
'{"tabs":[2]}',
|
||||
);
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("setDefault clears any prior default and rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const a = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const b = await repo.create("user-1", { name: "B", payload: "{}" });
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
await repo.setDefault("user-1", a.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(true);
|
||||
|
||||
await repo.setDefault("user-1", b.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
|
||||
expect((await repo.findById("user-1", b.id))?.isDefault).toBe(true);
|
||||
|
||||
const rejected = await repo.setDefault("user-1", lastSession.id);
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("unsetDefault clears isDefault on a manual workspace and rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const a = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
await repo.setDefault("user-1", a.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(true);
|
||||
|
||||
await repo.unsetDefault("user-1", a.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
|
||||
|
||||
const rejected = await repo.unsetDefault("user-1", lastSession.id);
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("touchLastUsed sets lastUsedAt", async () => {
|
||||
const repo = await createRepository();
|
||||
const workspace = await repo.create("user-1", {
|
||||
name: "A",
|
||||
payload: "{}",
|
||||
});
|
||||
expect(workspace.lastUsedAt).toBeNull();
|
||||
|
||||
await repo.touchLastUsed(
|
||||
"user-1",
|
||||
workspace.id,
|
||||
"2026-08-11T00:00:00.000Z",
|
||||
);
|
||||
expect((await repo.findById("user-1", workspace.id))?.lastUsedAt).toBe(
|
||||
"2026-08-11T00:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("delete removes a manual workspace but rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const manual = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
await expect(repo.delete("user-1", lastSession.id)).resolves.toBe(false);
|
||||
await expect(repo.delete("user-1", manual.id)).resolves.toBe(true);
|
||||
expect(await repo.findById("user-1", manual.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("triggers writes on create/update/delete", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const created = await repo.create("user-1", {
|
||||
name: "A",
|
||||
payload: "{}",
|
||||
});
|
||||
await repo.update("user-1", created.id, { name: "B" });
|
||||
await repo.delete("user-1", created.id);
|
||||
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deleteByUserId removes every workspace owned by the user", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
await repo.create("user-1", { name: "B", payload: "{}" });
|
||||
await repo.create("user-2", { name: "C", payload: "{}" });
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
expect(await repo.listByUser("user-1")).toHaveLength(0);
|
||||
expect(await repo.listByUser("user-2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
import crypto from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { AutomationDefinition } from "../../../../types/automations.js";
|
||||
|
||||
/**
|
||||
* Route-level behaviour: validation, ownership and webhook token handling. The repository and engine are mocked; what matters here is what
|
||||
* the HTTP layer accepts, rejects and hands back.
|
||||
*/
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "user-1",
|
||||
rows: [] as Array<Record<string, unknown>>,
|
||||
nextId: 1,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const repository = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
findForUser: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
listAllEnabled: vi.fn(),
|
||||
listRuns: vi.fn(),
|
||||
findRunForUser: vi.fn(),
|
||||
listRunSteps: vi.fn(),
|
||||
upsertSchedule: vi.fn(),
|
||||
deleteSchedule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
|
||||
const run = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../automations/engine.js", () => ({
|
||||
AutomationEngine: { getInstance: () => ({ run }) },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => undefined),
|
||||
getAuditUsername: vi.fn(async () => "alice"),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/automations.js");
|
||||
|
||||
function findHandler(method: string, path: string) {
|
||||
const stack = (router as unknown as Router).stack as Array<{
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
|
||||
};
|
||||
}>;
|
||||
const layer = stack.find(
|
||||
(l) => l.route?.path === path && l.route?.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
return layer.route.stack[layer.route.stack.length - 1].handle;
|
||||
}
|
||||
|
||||
/** Runs the whole middleware chain for the route, not just its handler. */
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const stack = (router as unknown as Router).stack as Array<{
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: (...args: unknown[]) => unknown }>;
|
||||
};
|
||||
}>;
|
||||
const layer = stack.find(
|
||||
(l) => l.route?.path === path && l.route?.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
|
||||
const req = {
|
||||
userId: state.currentUserId,
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
query: overrides.query ?? {},
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
(this as unknown as { statusCode: number }).statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||
|
||||
for (const entry of layer.route.stack) {
|
||||
let advanced = false;
|
||||
await entry.handle(req, res, () => {
|
||||
advanced = true;
|
||||
});
|
||||
if (!advanced) break;
|
||||
}
|
||||
|
||||
return res as unknown as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
function definition(
|
||||
overrides: Partial<AutomationDefinition> = {},
|
||||
): AutomationDefinition {
|
||||
return {
|
||||
version: 1,
|
||||
trigger: {
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 7 },
|
||||
metric: { path: "disk.percent", mount: "/data" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
cooldownMinutes: 15,
|
||||
},
|
||||
steps: [{ id: "a", type: "notify", channelIds: [1] }],
|
||||
...overrides,
|
||||
} as AutomationDefinition;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "user-1";
|
||||
state.rows = [];
|
||||
state.nextId = 1;
|
||||
vi.clearAllMocks();
|
||||
|
||||
repository.list.mockImplementation(async (userId: string) =>
|
||||
state.rows.filter((row) => row.user_id === userId),
|
||||
);
|
||||
repository.findForUser.mockImplementation(
|
||||
async (id: number, userId: string) =>
|
||||
state.rows.find((row) => row.id === id && row.user_id === userId) ?? null,
|
||||
);
|
||||
repository.create.mockImplementation(
|
||||
async (input: Record<string, unknown>) => {
|
||||
const row = {
|
||||
id: state.nextId++,
|
||||
user_id: input.userId,
|
||||
name: input.name,
|
||||
definition: input.definition,
|
||||
enabled: input.enabled === false ? 0 : 1,
|
||||
channels: input.channels ?? [],
|
||||
};
|
||||
state.rows.push(row);
|
||||
return row;
|
||||
},
|
||||
);
|
||||
repository.delete.mockImplementation(async (id: number, userId: string) => {
|
||||
const index = state.rows.findIndex(
|
||||
(row) => row.id === id && row.user_id === userId,
|
||||
);
|
||||
if (index === -1) return false;
|
||||
state.rows.splice(index, 1);
|
||||
return true;
|
||||
});
|
||||
repository.listAllEnabled.mockImplementation(async () =>
|
||||
state.rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
definition: row.definition,
|
||||
enabled: true,
|
||||
})),
|
||||
);
|
||||
repository.upsertSchedule.mockResolvedValue(undefined);
|
||||
repository.deleteSchedule.mockResolvedValue(undefined);
|
||||
run.mockResolvedValue({ runId: 1, status: "success" });
|
||||
});
|
||||
|
||||
describe("POST /", () => {
|
||||
it("creates an automation from a valid definition", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { name: "Disk watch", definition: definition() },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.jsonBody?.name).toBe("Disk watch");
|
||||
});
|
||||
|
||||
it("requires a name", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { definition: definition() },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/name/i);
|
||||
});
|
||||
|
||||
it("rejects an unknown trigger kind", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: { version: 1, trigger: { kind: "nope" }, steps: [] },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/trigger/i);
|
||||
});
|
||||
|
||||
it("rejects an unknown operator", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: definition({
|
||||
trigger: {
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "all" },
|
||||
metric: { path: "cpu.percent" },
|
||||
operator: "~=",
|
||||
value: 1,
|
||||
cooldownMinutes: 5,
|
||||
},
|
||||
} as Partial<AutomationDefinition>),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/operator/i);
|
||||
});
|
||||
|
||||
it("rejects an unknown step type", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: definition({
|
||||
steps: [{ id: "a", type: "launch_missiles" }],
|
||||
} as Partial<AutomationDefinition>),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/step type/i);
|
||||
});
|
||||
|
||||
it("rejects duplicate step ids, including inside a branch", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: definition({
|
||||
steps: [
|
||||
{ id: "dup", type: "wait", seconds: 1 },
|
||||
{
|
||||
id: "branch",
|
||||
type: "if",
|
||||
condition: { left: "1", operator: "==", right: "1" },
|
||||
then: [{ id: "dup", type: "wait", seconds: 1 }],
|
||||
},
|
||||
],
|
||||
} as Partial<AutomationDefinition>),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/duplicate/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid cron expression", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: definition({
|
||||
trigger: { kind: "schedule", cron: "not a cron" },
|
||||
} as Partial<AutomationDefinition>),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/cron/i);
|
||||
});
|
||||
|
||||
it("rejects an interval under a minute", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Bad",
|
||||
definition: definition({
|
||||
trigger: { kind: "schedule", intervalSeconds: 5 },
|
||||
} as Partial<AutomationDefinition>),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(String(res.jsonBody?.error)).toMatch(/60 seconds/i);
|
||||
});
|
||||
|
||||
it("registers a schedule for a schedule trigger", async () => {
|
||||
await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Nightly",
|
||||
definition: definition({
|
||||
trigger: { kind: "schedule", cron: "0 2 * * *" },
|
||||
} as Partial<AutomationDefinition>),
|
||||
},
|
||||
});
|
||||
expect(repository.upsertSchedule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a webhook token once and stores only its hash", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Hooked",
|
||||
definition: definition({
|
||||
trigger: { kind: "webhook", tokenHash: "" },
|
||||
} as Partial<AutomationDefinition>),
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const token = res.jsonBody?.webhookToken as string;
|
||||
expect(token).toMatch(/^[a-f0-9]{64}$/);
|
||||
|
||||
const stored = JSON.parse(state.rows[0].definition as string);
|
||||
expect(stored.trigger.tokenHash).not.toBe(token);
|
||||
expect(stored.trigger.tokenHash).toBe(
|
||||
crypto.createHash("sha256").update(token).digest("hex"),
|
||||
);
|
||||
|
||||
// The hash is never echoed back to the client.
|
||||
const body = res.jsonBody as { definition: AutomationDefinition };
|
||||
expect((body.definition.trigger as { tokenHash: string }).tokenHash).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("stores the automation against the caller, not a supplied user id", async () => {
|
||||
// Authorization here is ownership, the same as the other data routes:
|
||||
// every read and write is scoped to req.userId, so a client cannot create
|
||||
// an automation that belongs to somebody else.
|
||||
await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Mine",
|
||||
definition: definition(),
|
||||
userId: "user-2",
|
||||
user_id: "user-2",
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.rows).toHaveLength(1);
|
||||
expect(state.rows[0].user_id).toBe("user-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /", () => {
|
||||
it("only returns the caller's automations", async () => {
|
||||
state.rows.push({
|
||||
id: 1,
|
||||
user_id: "user-2",
|
||||
name: "Theirs",
|
||||
definition: JSON.stringify(definition()),
|
||||
channels: [],
|
||||
});
|
||||
|
||||
const res = await invoke("get", "/");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /:id", () => {
|
||||
it("will not delete another user's automation", async () => {
|
||||
state.rows.push({
|
||||
id: 1,
|
||||
user_id: "user-2",
|
||||
name: "Theirs",
|
||||
definition: JSON.stringify(definition()),
|
||||
channels: [],
|
||||
});
|
||||
|
||||
const res = await invoke("delete", "/:id", { params: { id: "1" } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(state.rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/run", () => {
|
||||
function seedOwned() {
|
||||
state.rows.push({
|
||||
id: 1,
|
||||
user_id: "user-1",
|
||||
name: "Mine",
|
||||
definition: JSON.stringify(definition()),
|
||||
channels: [],
|
||||
});
|
||||
}
|
||||
|
||||
it("runs an owned automation", async () => {
|
||||
seedOwned();
|
||||
const res = await invoke("post", "/:id/run", { params: { id: "1" } });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ automationId: 1, triggerType: "manual" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the dry-run flag through", async () => {
|
||||
seedOwned();
|
||||
await invoke("post", "/:id/run", {
|
||||
params: { id: "1" },
|
||||
body: { dryRun: true },
|
||||
});
|
||||
expect(run).toHaveBeenCalledWith(expect.objectContaining({ dryRun: true }));
|
||||
});
|
||||
|
||||
it("refuses to run someone else's automation", async () => {
|
||||
state.rows.push({
|
||||
id: 1,
|
||||
user_id: "user-2",
|
||||
name: "Theirs",
|
||||
definition: JSON.stringify(definition()),
|
||||
channels: [],
|
||||
});
|
||||
|
||||
const res = await invoke("post", "/:id/run", { params: { id: "1" } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /webhook/:token", () => {
|
||||
function seedWebhook(token: string) {
|
||||
state.rows.push({
|
||||
id: 1,
|
||||
user_id: "user-1",
|
||||
name: "Hooked",
|
||||
definition: JSON.stringify(
|
||||
definition({
|
||||
trigger: {
|
||||
kind: "webhook",
|
||||
tokenHash: crypto.createHash("sha256").update(token).digest("hex"),
|
||||
},
|
||||
} as Partial<AutomationDefinition>),
|
||||
),
|
||||
channels: [],
|
||||
});
|
||||
}
|
||||
|
||||
it("runs the automation matching the token", async () => {
|
||||
const token = "a".repeat(64);
|
||||
seedWebhook(token);
|
||||
|
||||
const res = await invoke("post", "/webhook/:token", {
|
||||
params: { token },
|
||||
body: { hello: "world" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ automationId: 1, triggerType: "webhook" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a token that does not match", async () => {
|
||||
seedWebhook("a".repeat(64));
|
||||
|
||||
const res = await invoke("post", "/webhook/:token", {
|
||||
params: { token: "b".repeat(64) },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a token too short to be real", async () => {
|
||||
seedWebhook("a".repeat(64));
|
||||
|
||||
const res = await invoke("post", "/webhook/:token", {
|
||||
params: { token: "short" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -24,9 +24,13 @@ describe("isLoopbackRequest", () => {
|
||||
it.each(["127.0.0.1", "::1", "::ffff:127.0.0.1"])(
|
||||
"accepts %s as loopback",
|
||||
(ip) => {
|
||||
expect(isLoopbackRequest({ ip, socket: {} } as unknown as Request)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isLoopbackRequest({
|
||||
ip,
|
||||
headers: {},
|
||||
socket: { remoteAddress: ip },
|
||||
} as unknown as Request),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -34,27 +38,40 @@ describe("isLoopbackRequest", () => {
|
||||
expect(
|
||||
isLoopbackRequest({
|
||||
ip: "::ffff:127.0.0.1",
|
||||
socket: {},
|
||||
headers: {},
|
||||
socket: { remoteAddress: "::ffff:127.0.0.1" },
|
||||
} as unknown as Request),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-loopback IP", () => {
|
||||
it("rejects a non-loopback TCP peer address", () => {
|
||||
expect(
|
||||
isLoopbackRequest({
|
||||
ip: "192.168.1.50",
|
||||
socket: {},
|
||||
headers: {},
|
||||
socket: { remoteAddress: "192.168.1.50" },
|
||||
} as unknown as Request),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to socket.remoteAddress when req.ip is empty", () => {
|
||||
it("ignores a spoofed X-Forwarded-For value", () => {
|
||||
expect(
|
||||
isLoopbackRequest({
|
||||
ip: "",
|
||||
ip: "127.0.0.1",
|
||||
headers: { "x-forwarded-for": "127.0.0.1" },
|
||||
socket: { remoteAddress: "203.0.113.10" },
|
||||
} as unknown as Request),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects requests that traversed the reverse proxy (X-Real-IP set)", () => {
|
||||
expect(
|
||||
isLoopbackRequest({
|
||||
ip: "127.0.0.1",
|
||||
headers: { "x-real-ip": "203.0.113.10" },
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as Request),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Request, Response, Router } from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "user-1",
|
||||
fleets: new Map<number, { id: number; userId: string; name: string }>(),
|
||||
members: new Map<number, { id: number; name: string }[]>(),
|
||||
hostAccess: new Map<string, { hasAccess: boolean; isOwner: boolean }>(),
|
||||
hosts: new Map<number, Record<string, unknown>>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
databaseLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async (userId: string, hostId: number, level: string) => {
|
||||
const key = `${userId}:${hostId}:${level}`;
|
||||
const found = state.hostAccess.get(key);
|
||||
if (found) return found;
|
||||
// default: full access unless a test explicitly denies it
|
||||
return { hasAccess: true, isOwner: true, permissionLevel: "manage" };
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/ssh-client-factory.js", () => ({
|
||||
getFleetPoolKey: () => "pool-key",
|
||||
createFleetSshFactory: () => async () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
_key: string,
|
||||
_factory: unknown,
|
||||
fn: (client: unknown) => unknown,
|
||||
) => fn({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: vi.fn(async () => ({ stdout: "ok", stderr: "", code: 0 })),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/metrics/managers/platform.js", () => ({
|
||||
detectPlatform: vi.fn(async () => ({ pkg: "apt", osPrettyName: "Debian" })),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/metrics/managers/exec-elevated.js", async () => {
|
||||
class ElevationError extends Error {
|
||||
code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
return {
|
||||
execElevated: vi.fn(),
|
||||
ElevationError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../hosts/metrics/managers/packages.js", () => ({
|
||||
buildPackageActionCommand: vi.fn(() => "apt-get install -y foo"),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/metrics/managers/validation.js", () => ({
|
||||
isValidPackageName: (v: unknown) => typeof v === "string" && v.length > 0,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/snippets-execution.js", () => ({
|
||||
resolveSnippetCommand: (command: string) => command,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/rbac.js", () => ({
|
||||
isSharePermissionLevel: (v: unknown) =>
|
||||
["connect", "view", "edit", "manage"].includes(v as string),
|
||||
expiryFromDuration: () => null,
|
||||
parseShareTargets: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentFleetRepository: () => ({
|
||||
findById: async (userId: string, fleetId: number) => {
|
||||
const fleet = state.fleets.get(fleetId);
|
||||
return fleet && fleet.userId === userId ? fleet : null;
|
||||
},
|
||||
listEffectiveMembers: async (_userId: string, fleetId: number) =>
|
||||
state.members.get(fleetId) ?? [],
|
||||
listStaticMemberIds: async () => [],
|
||||
listByUser: async (userId: string) =>
|
||||
[...state.fleets.values()].filter((f) => f.userId === userId),
|
||||
}),
|
||||
createCurrentFleetInventoryRepository: () => ({
|
||||
listForHosts: async () => [],
|
||||
upsert: async () => ({}),
|
||||
}),
|
||||
createCurrentRbacAccessRepository: () => ({}),
|
||||
createCurrentRoleRepository: () => ({}),
|
||||
createCurrentUserRepository: () => ({}),
|
||||
}));
|
||||
|
||||
const {
|
||||
default: router,
|
||||
parseInventoryProbe,
|
||||
buildRemoveCommand,
|
||||
} = await import("../../../database/routes/fleet-routes.js");
|
||||
|
||||
function findLayer(method: string, path: string) {
|
||||
const stack = (router as unknown as Router).stack as Array<{
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
|
||||
};
|
||||
}>;
|
||||
const layer = stack.find(
|
||||
(l) => l.route?.path === path && l.route?.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
return layer.route.stack[layer.route.stack.length - 1].handle;
|
||||
}
|
||||
|
||||
function makeReqRes(overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
}) {
|
||||
const req = {
|
||||
userId: state.currentUserId,
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
(this as unknown as { statusCode: number }).statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const handler = findLayer(method, path);
|
||||
const { req, res } = makeReqRes(overrides);
|
||||
await handler(req, res);
|
||||
return res as unknown as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "user-1";
|
||||
state.fleets = new Map([[1, { id: 1, userId: "user-1", name: "web fleet" }]]);
|
||||
state.members = new Map([
|
||||
[
|
||||
1,
|
||||
[
|
||||
{ id: 10, name: "host-a" },
|
||||
{ id: 11, name: "host-b" },
|
||||
],
|
||||
],
|
||||
]);
|
||||
state.hostAccess = new Map();
|
||||
state.hosts = new Map([
|
||||
[
|
||||
10,
|
||||
{
|
||||
id: 10,
|
||||
userId: "user-1",
|
||||
name: "host-a",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
sudoPassword: "secret",
|
||||
},
|
||||
],
|
||||
[
|
||||
11,
|
||||
{
|
||||
id: 11,
|
||||
userId: "user-1",
|
||||
name: "host-b",
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
sudoPassword: "secret",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("GET /:id/members", () => {
|
||||
it("404s for a fleet the caller does not own", async () => {
|
||||
const res = await invoke("get", "/:id/members", {
|
||||
params: { id: "999" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("400s on a non-numeric fleet id", async () => {
|
||||
const res = await invoke("get", "/:id/members", {
|
||||
params: { id: "not-a-number" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/execute", () => {
|
||||
it("400s when command is missing", async () => {
|
||||
const res = await invoke("post", "/:id/execute", {
|
||||
params: { id: "1" },
|
||||
body: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("reports per-host success with the {hostId, hostName, success} shape", async () => {
|
||||
const res = await invoke("post", "/:id/execute", {
|
||||
params: { id: "1" },
|
||||
body: { command: "uptime" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toMatchObject({
|
||||
hostId: 10,
|
||||
hostName: "host-a",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("isolates one host's access denial - the other host still succeeds", async () => {
|
||||
state.hostAccess.set("user-1:10:edit", {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
});
|
||||
|
||||
const res = await invoke("post", "/:id/execute", {
|
||||
params: { id: "1" },
|
||||
body: { command: "uptime" },
|
||||
});
|
||||
|
||||
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
|
||||
const denied = results.find((r) => r.hostId === 10);
|
||||
const allowed = results.find((r) => r.hostId === 11);
|
||||
expect(denied).toMatchObject({ success: false });
|
||||
expect(String(denied?.error)).toMatch(/edit/);
|
||||
expect(allowed).toMatchObject({ success: true });
|
||||
});
|
||||
|
||||
it("404s for a fleet the caller does not own", async () => {
|
||||
const res = await invoke("post", "/:id/execute", {
|
||||
params: { id: "999" },
|
||||
body: { command: "uptime" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/packages", () => {
|
||||
it("400s on an invalid action", async () => {
|
||||
const res = await invoke("post", "/:id/packages", {
|
||||
params: { id: "1" },
|
||||
body: { action: "reformat-disk" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("surfaces an ElevationError as a per-host error, not a request failure", async () => {
|
||||
const { execElevated, ElevationError } =
|
||||
await import("../../../hosts/metrics/managers/exec-elevated.js");
|
||||
(execElevated as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new ElevationError("SUDO_REQUIRED", "sudo password required"),
|
||||
);
|
||||
|
||||
const res = await invoke("post", "/:id/packages", {
|
||||
params: { id: "1" },
|
||||
body: { action: "install", package: "curl" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
|
||||
expect(results.every((r) => r.success === false)).toBe(true);
|
||||
expect(results[0].error).toMatch(/sudo password required/);
|
||||
});
|
||||
|
||||
it("requires manage-level access, not just edit", async () => {
|
||||
state.hostAccess.set("user-1:10:manage", {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
});
|
||||
state.hostAccess.set("user-1:11:manage", {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
});
|
||||
|
||||
const { execElevated } =
|
||||
await import("../../../hosts/metrics/managers/exec-elevated.js");
|
||||
(execElevated as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
code: 0,
|
||||
stdout: "done",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const res = await invoke("post", "/:id/packages", {
|
||||
params: { id: "1" },
|
||||
body: { action: "upgrade-all" },
|
||||
});
|
||||
|
||||
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
|
||||
expect(results.every((r) => r.success === false)).toBe(true);
|
||||
expect(String(results[0].error)).toMatch(/manage/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/members", () => {
|
||||
it("404s when the caller cannot access the target host", async () => {
|
||||
state.hostAccess.set("user-1:99:connect", {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
});
|
||||
|
||||
const res = await invoke("post", "/:id/members", {
|
||||
params: { id: "1" },
|
||||
body: { hostId: 99 },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseInventoryProbe", () => {
|
||||
it("extracts kernel, arch, hostname, and uptime from key=value lines", () => {
|
||||
const out = [
|
||||
"kernel=6.1.0-generic",
|
||||
"arch=x86_64",
|
||||
"hostname=web-1",
|
||||
"uptime_seconds=123456",
|
||||
].join("\n");
|
||||
|
||||
expect(parseInventoryProbe(out)).toEqual({
|
||||
kernel: "6.1.0-generic",
|
||||
architecture: "x86_64",
|
||||
hostname: "web-1",
|
||||
uptimeSeconds: 123456,
|
||||
});
|
||||
});
|
||||
|
||||
it("nulls out fields missing from the probe output", () => {
|
||||
expect(parseInventoryProbe("kernel=6.1.0")).toEqual({
|
||||
kernel: "6.1.0",
|
||||
architecture: null,
|
||||
hostname: null,
|
||||
uptimeSeconds: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("nulls uptimeSeconds when the value is not a bare integer", () => {
|
||||
const out = "uptime_seconds=";
|
||||
expect(parseInventoryProbe(out).uptimeSeconds).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores lines with no '=' separator", () => {
|
||||
const out = ["garbage line", "kernel=6.1.0"].join("\n");
|
||||
expect(parseInventoryProbe(out).kernel).toBe("6.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRemoveCommand", () => {
|
||||
it("builds the correct remove command per package manager", () => {
|
||||
expect(buildRemoveCommand("apt", "curl")).toContain(
|
||||
"apt-get -y remove curl",
|
||||
);
|
||||
expect(buildRemoveCommand("dnf", "curl")).toBe("dnf -y remove curl");
|
||||
expect(buildRemoveCommand("yum", "curl")).toBe("yum -y remove curl");
|
||||
expect(buildRemoveCommand("pacman", "curl")).toBe(
|
||||
"pacman -R --noconfirm curl",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no package manager was detected", () => {
|
||||
expect(buildRemoveCommand(null, "curl")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -222,6 +222,20 @@ describe("stripSensitiveFields", () => {
|
||||
expect(result.hasKey).toBe(false);
|
||||
});
|
||||
|
||||
it("detects sudo password stored only in nested terminalConfig", () => {
|
||||
const result = stripSensitiveFields({
|
||||
name: "web",
|
||||
terminalConfig: {
|
||||
theme: "termix",
|
||||
sudoPassword: "nested-only-sudo",
|
||||
},
|
||||
});
|
||||
expect(result.hasSudoPassword).toBe(true);
|
||||
expect(
|
||||
(result.terminalConfig as Record<string, unknown>).sudoPassword,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips rdp/vnc/telnet passwords and adds their presence flags", () => {
|
||||
const result = stripSensitiveFields({
|
||||
name: "rdp-box",
|
||||
@@ -318,6 +332,7 @@ describe("sanitizeHostForRecipient", () => {
|
||||
port: 22,
|
||||
username: "root",
|
||||
folder: "servers",
|
||||
parentHostId: 17,
|
||||
tags: ["linux"],
|
||||
notes: "secret runbook",
|
||||
quickActions: [{ name: "restart", snippetId: "1" }],
|
||||
@@ -359,6 +374,21 @@ describe("sanitizeHostForRecipient", () => {
|
||||
expect(result.quickActions).toEqual(sharedHost.quickActions);
|
||||
});
|
||||
|
||||
it("never exposes parentHostId to a recipient, at any permission level", () => {
|
||||
// A recipient generally can't see (or share-permission on) the owner's
|
||||
// parent host row, so sub-host tree structure is never leaked -- a
|
||||
// shared host always renders at root for its recipient.
|
||||
expect(
|
||||
sanitizeHostForRecipient({ ...sharedHost }, "view").parentHostId,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
sanitizeHostForRecipient({ ...sharedHost }, "manage").parentHostId,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
sanitizeHostForRecipient({ ...sharedHost }, "connect").parentHostId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reduces connect-level hosts to connection essentials", () => {
|
||||
const result = sanitizeHostForRecipient(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listOwnHostParentLinks = vi.fn();
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
listOwnHostParentLinks,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("validateParentHostId", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects a host being set as its own parent", async () => {
|
||||
const { validateParentHostId } =
|
||||
await import("../../../database/routes/host-parent-validation.js");
|
||||
|
||||
const error = await validateParentHostId("user-1", 5, 5);
|
||||
expect(error).toMatch(/own parent/);
|
||||
expect(listOwnHostParentLinks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a parent host that doesn't belong to the user", async () => {
|
||||
listOwnHostParentLinks.mockResolvedValue([
|
||||
{ id: 1, parentHostId: null },
|
||||
{ id: 2, parentHostId: null },
|
||||
]);
|
||||
const { validateParentHostId } =
|
||||
await import("../../../database/routes/host-parent-validation.js");
|
||||
|
||||
const error = await validateParentHostId("user-1", 1, 99);
|
||||
expect(error).toMatch(/not found/);
|
||||
});
|
||||
|
||||
it("accepts a valid, cycle-free parent assignment", async () => {
|
||||
listOwnHostParentLinks.mockResolvedValue([
|
||||
{ id: 1, parentHostId: null },
|
||||
{ id: 2, parentHostId: null },
|
||||
]);
|
||||
const { validateParentHostId } =
|
||||
await import("../../../database/routes/host-parent-validation.js");
|
||||
|
||||
const error = await validateParentHostId("user-1", 2, 1);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects assigning a host under its own descendant (direct cycle)", async () => {
|
||||
// Zeus (1) currently has VM (2) as a child; assigning Zeus under VM
|
||||
// would form a two-node cycle.
|
||||
listOwnHostParentLinks.mockResolvedValue([
|
||||
{ id: 1, parentHostId: null },
|
||||
{ id: 2, parentHostId: 1 },
|
||||
]);
|
||||
const { validateParentHostId } =
|
||||
await import("../../../database/routes/host-parent-validation.js");
|
||||
|
||||
const error = await validateParentHostId("user-1", 1, 2);
|
||||
expect(error).toMatch(/descendant/);
|
||||
});
|
||||
|
||||
it("rejects assigning a host under a deeper descendant (multi-level cycle)", async () => {
|
||||
// Zeus (1) -> VM (2) -> Nested (3); assigning Zeus under Nested must
|
||||
// also be rejected, not just the direct-child case.
|
||||
listOwnHostParentLinks.mockResolvedValue([
|
||||
{ id: 1, parentHostId: null },
|
||||
{ id: 2, parentHostId: 1 },
|
||||
{ id: 3, parentHostId: 2 },
|
||||
]);
|
||||
const { validateParentHostId } =
|
||||
await import("../../../database/routes/host-parent-validation.js");
|
||||
|
||||
const error = await validateParentHostId("user-1", 1, 3);
|
||||
expect(error).toMatch(/descendant/);
|
||||
});
|
||||
|
||||
it("allows a create (no existing hostId) to target any owned host", async () => {
|
||||
listOwnHostParentLinks.mockResolvedValue([{ id: 1, parentHostId: null }]);
|
||||
const { validateParentHostId } =
|
||||
await import("../../../database/routes/host-parent-validation.js");
|
||||
|
||||
const error = await validateParentHostId("user-1", null, 1);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSnippetExecutionResult,
|
||||
getSnippetExecutionTimeoutMs,
|
||||
resolveSnippetCommand,
|
||||
} from "../../../database/routes/snippets-execution.js";
|
||||
|
||||
describe("snippet execution", () => {
|
||||
@@ -46,3 +47,33 @@ describe("snippet execution", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("resolveSnippetCommand", () => {
|
||||
const host = { ip: "10.0.0.5", username: "root", port: 22, name: "web-01" };
|
||||
|
||||
it("substitutes host variables per target host", () => {
|
||||
expect(
|
||||
resolveSnippetCommand("ssh $USER@$HOST -p $PORT # $NAME", host),
|
||||
).toBe("ssh root@10.0.0.5 -p 22 # web-01");
|
||||
});
|
||||
|
||||
it("supports brace syntax for host variables", () => {
|
||||
expect(resolveSnippetCommand("ping ${HOST}", host)).toBe("ping 10.0.0.5");
|
||||
});
|
||||
|
||||
it("substitutes input placeholders from inputValues", () => {
|
||||
expect(
|
||||
resolveSnippetCommand("nc -zv $HOST ${INPUT_1:Port}", host, {
|
||||
INPUT_1: "8080",
|
||||
}),
|
||||
).toBe("nc -zv 10.0.0.5 8080");
|
||||
});
|
||||
|
||||
it("leaves host variables literal when no host context is given", () => {
|
||||
expect(resolveSnippetCommand("ping $HOST", null)).toBe("ping $HOST");
|
||||
});
|
||||
|
||||
it("leaves input placeholders literal when no value was supplied", () => {
|
||||
expect(resolveSnippetCommand("echo $INPUT_1", null)).toBe("echo $INPUT_1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { SQLiteSyncDialect } from "drizzle-orm/sqlite-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { locateSyncRow } from "../../../database/routes/sync.js";
|
||||
import { hosts, userPreferences } from "../../../database/db/schema.js";
|
||||
|
||||
/**
|
||||
* A sync push locates the stored row twice: once to see whether it exists,
|
||||
* once to write it. Those lookups used to be spelled out separately, and only
|
||||
* the read knew about singleton entities — the write always keyed on
|
||||
* `table.id`.
|
||||
*
|
||||
* `user_preferences` is the only singleton, and the one synced table whose
|
||||
* primary key is `user_id` with no `id` column at all. `table.id` was
|
||||
* therefore `undefined` and drizzle emitted a comparison with nothing on its
|
||||
* left: `( = ? and "user_preferences"."user_id" = ?)`. The insert branch was
|
||||
* fine, so the first push of preferences succeeded and every push after it —
|
||||
* the steady state — failed with `SqliteError: near "=": syntax error`.
|
||||
*/
|
||||
describe("locateSyncRow", () => {
|
||||
const dialect = new SQLiteSyncDialect();
|
||||
|
||||
const toSql = (condition: Parameters<typeof dialect.sqlToQuery>[0]): string =>
|
||||
dialect.sqlToQuery(condition).sql;
|
||||
|
||||
/** `= ?` with no operand to its left — what used to reach SQLite. */
|
||||
const EMPTY_LEFT_OPERAND = /(^|\(|\band\b|\bor\b)\s*=\s*\?/;
|
||||
|
||||
it("keys a singleton entity on its owner", () => {
|
||||
const sql = toSql(locateSyncRow("userPreferences", "user-1", "ignored"));
|
||||
|
||||
expect(sql).toContain('"user_id"');
|
||||
expect(sql).not.toContain('"sync_id"');
|
||||
expect(sql).not.toMatch(EMPTY_LEFT_OPERAND);
|
||||
});
|
||||
|
||||
it("keys a regular entity on its sync id and owner", () => {
|
||||
const sql = toSql(locateSyncRow("hosts", "user-1", "sync-abc"));
|
||||
|
||||
expect(sql).toContain('"sync_id"');
|
||||
expect(sql).toContain('"user_id"');
|
||||
expect(sql).not.toMatch(EMPTY_LEFT_OPERAND);
|
||||
});
|
||||
|
||||
it("is the shape the id-keyed lookup could not produce", () => {
|
||||
// Guards the assertions above: the pattern really does catch the old SQL.
|
||||
const previous = and(
|
||||
eq((userPreferences as unknown as typeof hosts).id, 1),
|
||||
eq(userPreferences.userId, "user-1"),
|
||||
)!;
|
||||
|
||||
expect(toSql(previous)).toMatch(EMPTY_LEFT_OPERAND);
|
||||
});
|
||||
|
||||
it("updates a preferences row that already exists", () => {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
theme TEXT
|
||||
);
|
||||
`);
|
||||
const db = drizzle(sqlite, { schema: { userPreferences } });
|
||||
|
||||
// The steady state: a row exists, so the push takes the update path.
|
||||
sqlite
|
||||
.prepare("INSERT INTO user_preferences (user_id, theme) VALUES (?, ?)")
|
||||
.run("user-1", "dark");
|
||||
|
||||
const updated = db
|
||||
.update(userPreferences)
|
||||
.set({ theme: "light" })
|
||||
.where(locateSyncRow("userPreferences", "user-1", "ignored"))
|
||||
.returning({ theme: userPreferences.theme })
|
||||
.all();
|
||||
|
||||
expect(updated).toEqual([{ theme: "light" }]);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import express, {
|
||||
type Request,
|
||||
type RequestHandler,
|
||||
type Response,
|
||||
} from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "admin-1",
|
||||
settings: {} as Record<string, string>,
|
||||
sessions: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
getUserSessions: () => state.sessions,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings[key] ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings[key] = value;
|
||||
},
|
||||
setMany: async (writes: Array<{ key: string; value: string }>) => {
|
||||
for (const write of writes) state.settings[write.key] = write.value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { registerUserImageStorageRoutes } =
|
||||
await import("../../../database/routes/user-image-storage-routes.js");
|
||||
|
||||
const requireAdmin: RequestHandler = (_req, _res, next) => next();
|
||||
const router = express.Router();
|
||||
registerUserImageStorageRoutes(router, requireAdmin);
|
||||
|
||||
interface RouteLayer {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: RequestHandler }>;
|
||||
};
|
||||
}
|
||||
|
||||
function handlerFor(pathName: string, method: string): RequestHandler {
|
||||
const layer = (router as unknown as { stack: RouteLayer[] }).stack.find(
|
||||
(candidate) =>
|
||||
candidate.route?.path === pathName && candidate.route.methods[method],
|
||||
);
|
||||
if (!layer) throw new Error(`Route not registered: ${method} ${pathName}`);
|
||||
return layer.route!.stack[layer.route!.stack.length - 1]!.handle;
|
||||
}
|
||||
|
||||
const getHandler = handlerFor("/terminal-image-storage-settings", "get");
|
||||
const patchHandler = handlerFor("/terminal-image-storage-settings", "patch");
|
||||
const testHandler = handlerFor("/terminal-image-storage-settings/test", "post");
|
||||
|
||||
async function invoke(handler: RequestHandler, body?: unknown) {
|
||||
const req = {
|
||||
userId: state.userId,
|
||||
body: body ?? {},
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
const result = { statusCode: 200, body: null as unknown };
|
||||
const res = {
|
||||
status(code: number) {
|
||||
result.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
result.body = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response;
|
||||
await handler(req, res, () => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Fake ssh2 exec channel: every command exits 0. */
|
||||
function fakeSshConn() {
|
||||
return {
|
||||
exec(
|
||||
_command: string,
|
||||
callback: (error: Error | undefined, stream?: unknown) => void,
|
||||
) {
|
||||
const stream = new EventEmitter() as EventEmitter & {
|
||||
resume: () => void;
|
||||
};
|
||||
stream.resume = () => queueMicrotask(() => stream.emit("close", 0));
|
||||
callback(undefined, stream);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const LEGACY_ENV_NAMES = [
|
||||
"TERMIX_IMAGE_STORAGE_MODE",
|
||||
"TERMIX_IMAGE_DIR",
|
||||
"TERMIX_IMAGE_HOST_PATH",
|
||||
"TERMIX_IMAGE_TTL_MS",
|
||||
"TERMIX_MAX_IMAGE_COUNT",
|
||||
"TERMIX_MAX_IMAGE_STORAGE_BYTES",
|
||||
"DATA_DIR",
|
||||
];
|
||||
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
let localDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
state.settings = {};
|
||||
state.sessions = [];
|
||||
savedEnv = Object.fromEntries(
|
||||
LEGACY_ENV_NAMES.map((name) => [name, process.env[name]]),
|
||||
);
|
||||
for (const name of LEGACY_ENV_NAMES) delete process.env[name];
|
||||
// The settings validator rejects backslashes, so use a POSIX-style form of
|
||||
// the real temp dir. Windows still resolves it, so file checks keep working.
|
||||
localDir = (
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "termix-image-test-"))
|
||||
).replace(/\\/g, "/");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const name of LEGACY_ENV_NAMES) {
|
||||
if (savedEnv[name] === undefined) delete process.env[name];
|
||||
else process.env[name] = savedEnv[name];
|
||||
}
|
||||
await fs.rm(localDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("GET /users/terminal-image-storage-settings", () => {
|
||||
it("returns the public settings shape without the backend localDir", async () => {
|
||||
state.settings["terminal_image_storage_mode"] = "local";
|
||||
state.settings["terminal_image_local_dir"] = localDir;
|
||||
state.settings["terminal_image_host_path"] = "/mnt/images";
|
||||
|
||||
const response = await invoke(getHandler);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
mode: "local",
|
||||
hostPath: "/mnt/images",
|
||||
ttlMs: 3_600_000,
|
||||
maxCount: 100,
|
||||
maxBytes: 5_368_709_120,
|
||||
localMappingConfigured: true,
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain(localDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /users/terminal-image-storage-settings", () => {
|
||||
it("rejects an invalid mode with a safe 400", async () => {
|
||||
const response = await invoke(patchHandler, { mode: "nfs" });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
error: "Invalid value for mode",
|
||||
code: "IMAGE_STORAGE_SETTINGS_INVALID",
|
||||
field: "mode",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a relative localDir", async () => {
|
||||
const response = await invoke(patchHandler, { localDir: "images/tmp" });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_STORAGE_SETTINGS_INVALID",
|
||||
field: "localDir",
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain("images/tmp");
|
||||
});
|
||||
|
||||
it("rejects out-of-range numeric limits", async () => {
|
||||
const response = await invoke(patchHandler, { maxBytes: 1024 });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_STORAGE_SETTINGS_INVALID",
|
||||
field: "maxBytes",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown fields without writing anything", async () => {
|
||||
const response = await invoke(patchHandler, { shellPath: "/tmp/x" });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_STORAGE_SETTINGS_UNKNOWN_FIELD",
|
||||
});
|
||||
expect(state.settings).toEqual({});
|
||||
});
|
||||
|
||||
// On Windows path.resolve turns the stored dir back into a backslash path,
|
||||
// which the validator rejects on read, so the round trip only holds on POSIX.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"persists a valid partial update and returns the public shape",
|
||||
async () => {
|
||||
const response = await invoke(patchHandler, {
|
||||
mode: "local",
|
||||
localDir,
|
||||
hostPath: "/host/images",
|
||||
ttlMs: 60_000,
|
||||
maxCount: 5,
|
||||
maxBytes: 10_485_760,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(state.settings).toEqual({
|
||||
terminal_image_storage_mode: "local",
|
||||
terminal_image_local_dir: path.resolve(localDir),
|
||||
terminal_image_host_path: "/host/images",
|
||||
terminal_image_ttl_ms: "60000",
|
||||
terminal_image_max_count: "5",
|
||||
terminal_image_max_storage_bytes: "10485760",
|
||||
});
|
||||
expect(response.body).toMatchObject({
|
||||
mode: "local",
|
||||
hostPath: "/host/images",
|
||||
ttlMs: 60_000,
|
||||
maxCount: 5,
|
||||
maxBytes: 10_485_760,
|
||||
localMappingConfigured: true,
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain(localDir);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("POST /users/terminal-image-storage-settings/test", () => {
|
||||
it("requires an instanceId", async () => {
|
||||
const response = await invoke(testHandler, {});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({ code: "IMAGE_SESSION_MISSING" });
|
||||
});
|
||||
|
||||
it("reports unavailable storage when no session is connected", async () => {
|
||||
const response = await invoke(testHandler, { instanceId: "tab-1" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
mode: "auto",
|
||||
connected: false,
|
||||
remoteSftpAvailable: false,
|
||||
localHostVisible: null,
|
||||
selectedMode: "unavailable",
|
||||
localMappingConfigured: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("probes local visibility through the connected session only", async () => {
|
||||
state.settings["terminal_image_local_dir"] = localDir;
|
||||
state.settings["terminal_image_host_path"] = "/host/images";
|
||||
state.sessions = [
|
||||
{
|
||||
tabInstanceId: "tab-1",
|
||||
isConnected: true,
|
||||
sshConn: fakeSshConn(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await invoke(testHandler, { instanceId: "tab-1" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
mode: "auto",
|
||||
connected: true,
|
||||
remoteSftpAvailable: true,
|
||||
localHostVisible: true,
|
||||
selectedMode: "local",
|
||||
localMappingConfigured: true,
|
||||
});
|
||||
// The bounded probe cleans up after itself.
|
||||
expect(
|
||||
(await fs.readdir(localDir)).filter((f) => f.includes("probe")),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not probe sessions owned by other instance IDs", async () => {
|
||||
state.sessions = [
|
||||
{
|
||||
tabInstanceId: "tab-2",
|
||||
isConnected: true,
|
||||
sshConn: fakeSshConn(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await invoke(testHandler, { instanceId: "tab-1" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: false,
|
||||
remoteSftpAvailable: false,
|
||||
localHostVisible: null,
|
||||
selectedMode: "unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_IMAGE_HOST_PATH,
|
||||
DEFAULT_IMAGE_MAX_BYTES,
|
||||
DEFAULT_IMAGE_MAX_COUNT,
|
||||
DEFAULT_IMAGE_TTL_MS,
|
||||
defaultImageLocalDir,
|
||||
parseImageHostPath,
|
||||
parseImageLocalDir,
|
||||
parseTerminalImageStorageMode,
|
||||
resolveTerminalImageStorageSettings,
|
||||
TERMINAL_IMAGE_STORAGE_KEYS,
|
||||
} from "../../../database/routes/terminal-image-storage-settings.js";
|
||||
import { SettingsRepository } from "../../../database/repositories/settings-repository.js";
|
||||
import { TestSqliteDatabase } from "../repositories/test-support.js";
|
||||
|
||||
// parseImageLocalDir resolves against the host platform, so a POSIX literal
|
||||
// becomes a drive-rooted path on Windows. Compare against the same resolution.
|
||||
function localDir(posixPath: string): string {
|
||||
return path.resolve(posixPath);
|
||||
}
|
||||
|
||||
function stubSettings(values: Record<string, string> = {}) {
|
||||
return {
|
||||
get: async (key: string) => values[key] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_ENV: NodeJS.ProcessEnv = {};
|
||||
|
||||
describe("terminal image storage settings", () => {
|
||||
describe("mode parsing", () => {
|
||||
it("accepts the three documented modes case-insensitively", () => {
|
||||
expect(parseTerminalImageStorageMode("auto")).toBe("auto");
|
||||
expect(parseTerminalImageStorageMode("LOCAL")).toBe("local");
|
||||
expect(parseTerminalImageStorageMode(" remote-sftp ")).toBe(
|
||||
"remote-sftp",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown modes and non-strings", () => {
|
||||
expect(parseTerminalImageStorageMode("s3")).toBeNull();
|
||||
expect(parseTerminalImageStorageMode("")).toBeNull();
|
||||
expect(parseTerminalImageStorageMode(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("path validation", () => {
|
||||
it("accepts absolute local directories and normalizes them", () => {
|
||||
expect(parseImageLocalDir("/var/lib/termix/images")).toBe(
|
||||
localDir("/var/lib/termix/images"),
|
||||
);
|
||||
expect(parseImageLocalDir("/var/lib/termix/../termix/images")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects relative, empty and NUL-containing local directories", () => {
|
||||
expect(parseImageLocalDir("images")).toBeNull();
|
||||
expect(parseImageLocalDir("./db/data/images")).toBeNull();
|
||||
expect(parseImageLocalDir("")).toBeNull();
|
||||
expect(parseImageLocalDir("/tmp/a\0b")).toBeNull();
|
||||
});
|
||||
|
||||
it("requires the agent-visible host path to be POSIX-absolute", () => {
|
||||
expect(parseImageHostPath("/tmp/termix-image-v0")).toBe(
|
||||
"/tmp/termix-image-v0",
|
||||
);
|
||||
expect(parseImageHostPath("tmp/termix-image-v0")).toBeNull();
|
||||
expect(parseImageHostPath("C:\\images")).toBeNull();
|
||||
expect(parseImageHostPath("/tmp/a\0b")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolution precedence", () => {
|
||||
it("uses built-in defaults when neither the database nor env has values", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings(),
|
||||
EMPTY_ENV,
|
||||
);
|
||||
expect(resolved.mode).toBe("auto");
|
||||
expect(resolved.localDir).toBe(defaultImageLocalDir(EMPTY_ENV));
|
||||
expect(resolved.hostPath).toBe(DEFAULT_IMAGE_HOST_PATH);
|
||||
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
|
||||
expect(resolved.maxCount).toBe(DEFAULT_IMAGE_MAX_COUNT);
|
||||
expect(resolved.maxBytes).toBe(DEFAULT_IMAGE_MAX_BYTES);
|
||||
});
|
||||
|
||||
it("seeds defaults from legacy TERMIX_IMAGE_* env when no DB value exists", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings(),
|
||||
{
|
||||
TERMIX_IMAGE_DIR: "/host-tmp/images",
|
||||
TERMIX_IMAGE_HOST_PATH: "/tmp/images",
|
||||
TERMIX_IMAGE_TTL_MS: "60000",
|
||||
TERMIX_MAX_IMAGE_COUNT: "5",
|
||||
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10485760",
|
||||
},
|
||||
);
|
||||
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
|
||||
expect(resolved.hostPath).toBe("/tmp/images");
|
||||
expect(resolved.ttlMs).toBe(60_000);
|
||||
expect(resolved.maxCount).toBe(5);
|
||||
expect(resolved.maxBytes).toBe(10_485_760);
|
||||
});
|
||||
|
||||
it("lets persisted DB values win over legacy env values", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings({
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "/db/images",
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "1000",
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.maxCount]: "7",
|
||||
}),
|
||||
{
|
||||
TERMIX_IMAGE_DIR: "/host-tmp/images",
|
||||
TERMIX_IMAGE_TTL_MS: "60000",
|
||||
TERMIX_MAX_IMAGE_COUNT: "5",
|
||||
},
|
||||
);
|
||||
expect(resolved.localDir).toBe(localDir("/db/images"));
|
||||
expect(resolved.ttlMs).toBe(1_000);
|
||||
expect(resolved.maxCount).toBe(7);
|
||||
});
|
||||
|
||||
it("falls through to env and defaults when the DB value is invalid", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings({
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "relative/path",
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "not-a-number",
|
||||
}),
|
||||
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
|
||||
);
|
||||
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
|
||||
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
|
||||
});
|
||||
|
||||
it("keeps legacy explicit local mappings on local mode", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings(),
|
||||
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
|
||||
);
|
||||
expect(resolved.mode).toBe("local");
|
||||
// hostPath falls back to the legacy local dir, which resolves natively.
|
||||
expect(resolved.hostPath).toBe(localDir("/host-tmp/images"));
|
||||
expect(resolved.localMappingConfigured).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a persisted DB mode override the legacy local mapping", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings({
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: "remote-sftp",
|
||||
}),
|
||||
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
|
||||
);
|
||||
expect(resolved.mode).toBe("remote-sftp");
|
||||
});
|
||||
|
||||
it("clamps out-of-range numeric values like the legacy env parsing did", async () => {
|
||||
const resolved = await resolveTerminalImageStorageSettings(
|
||||
stubSettings(),
|
||||
{
|
||||
TERMIX_IMAGE_TTL_MS: "-5",
|
||||
TERMIX_MAX_IMAGE_COUNT: "0",
|
||||
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10",
|
||||
},
|
||||
);
|
||||
expect(resolved.ttlMs).toBe(0);
|
||||
expect(resolved.maxCount).toBe(1);
|
||||
expect(resolved.maxBytes).toBe(1_048_576);
|
||||
});
|
||||
|
||||
it("reads persisted values through the real settings repository", async () => {
|
||||
const adapter = new TestSqliteDatabase();
|
||||
try {
|
||||
const context = await adapter.connect();
|
||||
const repository = new SettingsRepository(context);
|
||||
await repository.set(TERMINAL_IMAGE_STORAGE_KEYS.mode, "local");
|
||||
await repository.set(
|
||||
TERMINAL_IMAGE_STORAGE_KEYS.localDir,
|
||||
"/persisted/images",
|
||||
);
|
||||
|
||||
const resolved = await resolveTerminalImageStorageSettings(repository, {
|
||||
TERMIX_IMAGE_DIR: "/host-tmp/images",
|
||||
});
|
||||
expect(resolved.mode).toBe("local");
|
||||
expect(resolved.localDir).toBe(localDir("/persisted/images"));
|
||||
} finally {
|
||||
await adapter.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,571 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
REMOTE_IMAGE_DIR,
|
||||
selectImageStorageMode,
|
||||
storeImageLocally,
|
||||
storeImageViaSftp,
|
||||
TerminalImageStorageError,
|
||||
type ImageSftpClient,
|
||||
} from "../../../database/routes/terminal-image-storage.js";
|
||||
import type { TerminalImageStorageSettings } from "../../../database/routes/terminal-image-storage-settings.js";
|
||||
|
||||
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
function settings(
|
||||
overrides: Partial<TerminalImageStorageSettings>,
|
||||
): TerminalImageStorageSettings {
|
||||
return {
|
||||
mode: "local",
|
||||
localDir: "/nonexistent",
|
||||
hostPath: "/tmp/termix-image-v0",
|
||||
ttlMs: 3_600_000,
|
||||
maxCount: 100,
|
||||
maxBytes: 5_368_709_120,
|
||||
localMappingConfigured: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectImageStorageMode", () => {
|
||||
it("keeps explicit modes deterministic regardless of capability", () => {
|
||||
expect(
|
||||
selectImageStorageMode(settings({ mode: "local" }), {
|
||||
remoteSftpAvailable: true,
|
||||
}),
|
||||
).toBe("local");
|
||||
expect(
|
||||
selectImageStorageMode(settings({ mode: "remote-sftp" }), {
|
||||
remoteSftpAvailable: false,
|
||||
}),
|
||||
).toBe("remote-sftp");
|
||||
});
|
||||
|
||||
it("falls back on capability only in auto mode", () => {
|
||||
expect(
|
||||
selectImageStorageMode(settings({ mode: "auto" }), {
|
||||
remoteSftpAvailable: true,
|
||||
}),
|
||||
).toBe("remote-sftp");
|
||||
expect(
|
||||
selectImageStorageMode(settings({ mode: "auto" }), {
|
||||
remoteSftpAvailable: false,
|
||||
}),
|
||||
).toBe("unavailable");
|
||||
expect(
|
||||
selectImageStorageMode(
|
||||
settings({ mode: "auto", localMappingConfigured: true }),
|
||||
{ remoteSftpAvailable: false, localHostVisible: true },
|
||||
),
|
||||
).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeImageLocally", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-images-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedFile(bytes: number, mtimeMs?: number): Promise<string> {
|
||||
const name = `${randomUUID()}.png`;
|
||||
const filePath = path.join(dir, name);
|
||||
await fs.writeFile(filePath, Buffer.alloc(bytes));
|
||||
if (mtimeMs !== undefined) {
|
||||
const date = new Date(mtimeMs);
|
||||
await fs.utimes(filePath, date, date);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
it("writes a UUID-named PNG and returns the agent-visible host path", async () => {
|
||||
const stored = await storeImageLocally(
|
||||
PNG_BYTES,
|
||||
settings({ localDir: dir, hostPath: "/host-view/images" }),
|
||||
);
|
||||
|
||||
expect(stored.storage).toBe("local");
|
||||
expect(stored.filename).toBe(`${stored.id}.png`);
|
||||
expect(stored.shellPath).toBe(
|
||||
path.posix.join("/host-view/images", stored.filename),
|
||||
);
|
||||
expect(stored.shellPath).not.toContain(dir);
|
||||
await expect(fs.readFile(path.join(dir, stored.filename))).resolves.toEqual(
|
||||
PNG_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the count cap is full", async () => {
|
||||
await seedFile(10);
|
||||
const error = await storeImageLocally(
|
||||
PNG_BYTES,
|
||||
settings({ localDir: dir, maxCount: 1 }),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the byte cap is full", async () => {
|
||||
await seedFile(900);
|
||||
const error = await storeImageLocally(
|
||||
Buffer.alloc(200),
|
||||
settings({ localDir: dir, maxBytes: 1_000 }),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans expired files during upload so they no longer count", async () => {
|
||||
await seedFile(10, Date.now() - 2 * 3_600_000);
|
||||
const stored = await storeImageLocally(
|
||||
PNG_BYTES,
|
||||
settings({ localDir: dir, maxCount: 1, ttlMs: 3_600_000 }),
|
||||
);
|
||||
|
||||
expect(stored.storage).toBe("local");
|
||||
const remaining = await fs.readdir(dir);
|
||||
expect(remaining).toEqual([stored.filename]);
|
||||
});
|
||||
|
||||
it("fails closed when local storage inspection is unavailable", async () => {
|
||||
const blocked = path.join(dir, "blocked-file");
|
||||
await fs.writeFile(blocked, "not a directory");
|
||||
|
||||
const error = await storeImageLocally(
|
||||
PNG_BYTES,
|
||||
settings({ localDir: blocked }),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_LOCAL_INSPECTION_FAILED",
|
||||
);
|
||||
});
|
||||
it("reports inspection failures before attempting a write", async () => {
|
||||
const blocked = path.join(dir, "blocked");
|
||||
await fs.writeFile(blocked, "not a directory");
|
||||
|
||||
const error = await storeImageLocally(
|
||||
PNG_BYTES,
|
||||
settings({ localDir: path.join(blocked, "images") }),
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_LOCAL_INSPECTION_FAILED",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeImageViaSftp", () => {
|
||||
function fakeSftp(behavior: {
|
||||
mkdirError?: Error;
|
||||
writeError?: Error;
|
||||
stallWrite?: boolean;
|
||||
stallLock?: boolean;
|
||||
stallReaddir?: boolean;
|
||||
stallRmdir?: boolean;
|
||||
stallUnlink?: boolean;
|
||||
readdirEntries?: Array<{ filename: string; mtime?: number; size?: number }>;
|
||||
readdirError?: Error;
|
||||
statMode?: number;
|
||||
}): {
|
||||
sftp: ImageSftpClient;
|
||||
written: Map<string, Buffer>;
|
||||
calls: {
|
||||
mkdir: Array<{ dir: string; mode?: number }>;
|
||||
createWriteStream: Array<{ path: string; mode?: number }>;
|
||||
};
|
||||
streams: Array<
|
||||
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
|
||||
>;
|
||||
} {
|
||||
const written = new Map<string, Buffer>();
|
||||
const streams: Array<
|
||||
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
|
||||
> = [];
|
||||
const calls = { mkdir: [], createWriteStream: [] } as {
|
||||
mkdir: Array<{ dir: string; mode?: number }>;
|
||||
createWriteStream: Array<{ path: string; mode?: number }>;
|
||||
};
|
||||
const sftp = {
|
||||
mkdir: (
|
||||
dir: string,
|
||||
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
|
||||
maybeCallback?: (err?: Error) => void,
|
||||
) => {
|
||||
const callback =
|
||||
typeof attrsOrCallback === "function"
|
||||
? attrsOrCallback
|
||||
: maybeCallback!;
|
||||
calls.mkdir.push({
|
||||
dir,
|
||||
mode:
|
||||
typeof attrsOrCallback === "function"
|
||||
? undefined
|
||||
: attrsOrCallback.mode,
|
||||
});
|
||||
if (
|
||||
behavior.stallLock &&
|
||||
dir === `${REMOTE_IMAGE_DIR}/.termix-write-lock`
|
||||
) {
|
||||
return;
|
||||
}
|
||||
callback(dir === REMOTE_IMAGE_DIR ? behavior.mkdirError : undefined);
|
||||
},
|
||||
stat: (
|
||||
_dir: string,
|
||||
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
|
||||
) => callback(undefined, { mode: behavior.statMode ?? 0o40700 }),
|
||||
chmod: (_dir: string, _mode: number, callback: (error?: Error) => void) =>
|
||||
callback(),
|
||||
readdir: (
|
||||
_dir: string,
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
entries: Array<{
|
||||
filename: string;
|
||||
attrs?: { mtime?: number; size?: number };
|
||||
}>,
|
||||
) => void,
|
||||
) => {
|
||||
if (behavior.stallReaddir) return;
|
||||
callback(
|
||||
behavior.readdirError,
|
||||
behavior.readdirEntries?.map((entry) => ({
|
||||
filename: entry.filename,
|
||||
attrs: entry,
|
||||
})) ?? [],
|
||||
);
|
||||
},
|
||||
createWriteStream: (remotePath: string, options?: { mode?: number }) => {
|
||||
calls.createWriteStream.push({ path: remotePath, mode: options?.mode });
|
||||
const stream = new EventEmitter() as NodeJS.WritableStream & {
|
||||
end: (data: Buffer) => void;
|
||||
destroy: () => void;
|
||||
destroyed: boolean;
|
||||
};
|
||||
stream.destroyed = false;
|
||||
stream.destroy = () => {
|
||||
stream.destroyed = true;
|
||||
};
|
||||
streams.push(stream);
|
||||
stream.end = (data: Buffer) => {
|
||||
if (behavior.stallWrite) return;
|
||||
queueMicrotask(() => {
|
||||
if (behavior.writeError) {
|
||||
stream.emit("error", behavior.writeError);
|
||||
return;
|
||||
}
|
||||
written.set(remotePath, data);
|
||||
stream.emit("close");
|
||||
});
|
||||
};
|
||||
return stream;
|
||||
},
|
||||
unlink: (_remotePath: string, callback: (error?: Error) => void) => {
|
||||
if (behavior.stallUnlink) return;
|
||||
callback();
|
||||
},
|
||||
rmdir: (_dir: string, callback: (error?: Error) => void) => {
|
||||
if (behavior.stallRmdir) return;
|
||||
callback();
|
||||
},
|
||||
} as unknown as ImageSftpClient;
|
||||
return { sftp, written, calls, streams };
|
||||
}
|
||||
|
||||
it("writes into the remote image directory and returns its POSIX path", async () => {
|
||||
const { sftp, written } = fakeSftp({});
|
||||
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
|
||||
|
||||
expect(stored.storage).toBe("remote-sftp");
|
||||
expect(stored.shellPath).toBe(`${REMOTE_IMAGE_DIR}/${stored.id}.png`);
|
||||
expect(written.get(stored.shellPath)).toEqual(PNG_BYTES);
|
||||
});
|
||||
|
||||
it("requests restrictive modes for the remote directory and file", async () => {
|
||||
const { sftp, calls } = fakeSftp({});
|
||||
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
|
||||
|
||||
expect(stored.storage).toBe("remote-sftp");
|
||||
expect(calls.mkdir).toEqual([
|
||||
{ dir: REMOTE_IMAGE_DIR, mode: 0o700 },
|
||||
{ dir: `${REMOTE_IMAGE_DIR}/.termix-write-lock`, mode: 0o700 },
|
||||
]);
|
||||
expect(calls.createWriteStream).toEqual([
|
||||
{ path: stored.shellPath, mode: 0o600 },
|
||||
]);
|
||||
});
|
||||
it("removes expired UUID PNGs with best-effort remote retention", async () => {
|
||||
const nowMs = 10_000_000;
|
||||
const expiredName = `${randomUUID()}.png`;
|
||||
const freshName = `${randomUUID()}.png`;
|
||||
const unlinked: string[] = [];
|
||||
const base = fakeSftp({});
|
||||
const sftp = base.sftp as ImageSftpClient & {
|
||||
readdir: (
|
||||
dir: string,
|
||||
callback: (
|
||||
err: Error | undefined,
|
||||
entries: Array<{ filename: string; attrs?: { mtime?: number } }>,
|
||||
) => void,
|
||||
) => void;
|
||||
unlink: (remotePath: string, callback: (err?: Error) => void) => void;
|
||||
};
|
||||
sftp.readdir = (_dir, callback) =>
|
||||
callback(undefined, [
|
||||
{ filename: expiredName, attrs: { mtime: (nowMs - 7_200_000) / 1000 } },
|
||||
{ filename: freshName, attrs: { mtime: nowMs / 1000 } },
|
||||
{ filename: "other.txt", attrs: { mtime: 0 } },
|
||||
]);
|
||||
sftp.unlink = (remotePath, callback) => {
|
||||
unlinked.push(remotePath);
|
||||
callback();
|
||||
};
|
||||
|
||||
await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
ttlMs: 3_600_000,
|
||||
nowMs,
|
||||
});
|
||||
|
||||
expect(unlinked).toEqual([`${REMOTE_IMAGE_DIR}/${expiredName}`]);
|
||||
});
|
||||
it("fails closed when remote quota inspection fails", async () => {
|
||||
const { sftp } = fakeSftp({
|
||||
readdirError: new Error("remote listing failed"),
|
||||
});
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
maxCount: 10,
|
||||
maxBytes: 1024,
|
||||
}).catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects remote writes at the configured count limit", async () => {
|
||||
const { sftp } = fakeSftp({
|
||||
readdirEntries: [{ filename: `${randomUUID()}.png`, size: 12 }],
|
||||
});
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
maxCount: 1,
|
||||
maxBytes: 1024,
|
||||
}).catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_STORAGE_LIMIT_REACHED",
|
||||
);
|
||||
});
|
||||
|
||||
it("destroys a stalled SFTP write after its timeout", async () => {
|
||||
const { sftp, streams } = fakeSftp({ stallWrite: true });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
writeTimeoutMs: 25,
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
expect(streams[0]!.destroyed).toBe(true);
|
||||
});
|
||||
it("rejects an existing remote path that is not a directory", async () => {
|
||||
const { sftp } = fakeSftp({
|
||||
mkdirError: new Error("Failure: file already exists"),
|
||||
statMode: 0o100644,
|
||||
});
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves SFTP client context for directory inspection", async () => {
|
||||
const base = fakeSftp({ mkdirError: new Error("already exists") });
|
||||
const sftp = base.sftp;
|
||||
sftp.lstat = function (
|
||||
this: ImageSftpClient,
|
||||
_dir: string,
|
||||
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
|
||||
) {
|
||||
if (this !== sftp) {
|
||||
callback(new Error("SFTP context lost"));
|
||||
return;
|
||||
}
|
||||
callback(undefined, { mode: 0o40700 });
|
||||
};
|
||||
const result = await storeImageViaSftp(sftp, PNG_BYTES);
|
||||
expect(result.storage).toBe("remote-sftp");
|
||||
});
|
||||
|
||||
it("tolerates mkdir failures for an already-existing directory", async () => {
|
||||
const { sftp } = fakeSftp({
|
||||
mkdirError: new Error("Failure: file already exists"),
|
||||
});
|
||||
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
|
||||
storage: "remote-sftp",
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans up a partially created remote file after a write failure", async () => {
|
||||
const { sftp } = fakeSftp({ writeError: new Error("write failed") });
|
||||
const unlinked: string[] = [];
|
||||
(sftp as ImageSftpClient).unlink = (remotePath, callback) => {
|
||||
unlinked.push(remotePath);
|
||||
callback();
|
||||
};
|
||||
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect(unlinked).toHaveLength(1);
|
||||
expect(unlinked[0]).toMatch(
|
||||
new RegExp(`${REMOTE_IMAGE_DIR}/[0-9a-f-]+\\.png`),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the write error when partial-file cleanup stalls", async () => {
|
||||
const { sftp } = fakeSftp({
|
||||
writeError: new Error("write failed"),
|
||||
stallUnlink: true,
|
||||
});
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
}, 5_000);
|
||||
|
||||
it("recovers a stale remote lock before writing", async () => {
|
||||
const base = fakeSftp({});
|
||||
const sftp = base.sftp as ImageSftpClient;
|
||||
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
|
||||
let lockAttempt = 0;
|
||||
const removed: string[] = [];
|
||||
const originalMkdir = sftp.mkdir.bind(sftp);
|
||||
sftp.mkdir = (dir, attrs, callback) => {
|
||||
if (dir === lockPath && lockAttempt++ === 0) {
|
||||
(typeof attrs === "function" ? attrs : callback!)(new Error("exists"));
|
||||
return;
|
||||
}
|
||||
originalMkdir(dir, attrs, callback);
|
||||
};
|
||||
sftp.lstat = (_dir, callback) =>
|
||||
callback(undefined, {
|
||||
mode: 0o40700,
|
||||
mtime: (Date.now() - 60_000) / 1000,
|
||||
});
|
||||
sftp.rmdir = (dir, callback) => {
|
||||
removed.push(dir);
|
||||
callback();
|
||||
};
|
||||
|
||||
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
|
||||
storage: "remote-sftp",
|
||||
});
|
||||
expect(removed).toContain(lockPath);
|
||||
});
|
||||
|
||||
it("does not replace a successful write with an unlock failure", async () => {
|
||||
const base = fakeSftp({});
|
||||
const sftp = base.sftp as ImageSftpClient;
|
||||
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
|
||||
const originalMkdir = sftp.mkdir.bind(sftp);
|
||||
sftp.mkdir = (dir, attrs, callback) => originalMkdir(dir, attrs, callback);
|
||||
sftp.rmdir = (dir, callback) => {
|
||||
if (dir === lockPath) callback(new Error("unlock failed"));
|
||||
else callback();
|
||||
};
|
||||
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when remote lock acquisition stalls", async () => {
|
||||
const { sftp } = fakeSftp({ stallLock: true });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
}, 12_000);
|
||||
|
||||
it("fails closed when remote quota inspection stalls", async () => {
|
||||
const { sftp } = fakeSftp({ stallReaddir: true });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
maxCount: 10,
|
||||
maxBytes: 1024,
|
||||
}).catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
}, 7_000);
|
||||
|
||||
it("fails closed when remote expiry cleanup stalls", async () => {
|
||||
const { sftp } = fakeSftp({ stallReaddir: true });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
|
||||
ttlMs: 1_000,
|
||||
}).catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
}, 7_000);
|
||||
|
||||
it("fails closed when remote lock release stalls", async () => {
|
||||
const { sftp } = fakeSftp({ stallRmdir: true });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
}, 5_000);
|
||||
|
||||
it("maps SFTP failures to IMAGE_REMOTE_WRITE_FAILED", async () => {
|
||||
const { sftp } = fakeSftp({ writeError: new Error("Permission denied") });
|
||||
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(TerminalImageStorageError);
|
||||
expect((error as TerminalImageStorageError).code).toBe(
|
||||
"IMAGE_REMOTE_WRITE_FAILED",
|
||||
);
|
||||
expect((error as TerminalImageStorageError).message).toBe(
|
||||
"Failed to write image to the remote host",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,541 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import sharp from "sharp";
|
||||
import type { Request, RequestHandler, Response } from "express";
|
||||
import type { ImageSftpClient } from "../../../database/routes/terminal-image-storage.js";
|
||||
import { databaseLogger } from "../../../utils/logger.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "user-1",
|
||||
settings: {} as Record<string, string>,
|
||||
sessions: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
getUserSessions: () => state.sessions,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings[key] ?? null,
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({}),
|
||||
createCurrentCommandHistoryRepository: () => ({}),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/terminal.js");
|
||||
|
||||
interface RouteLayer {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: RequestHandler }>;
|
||||
};
|
||||
}
|
||||
|
||||
const imageUploadLayer = (
|
||||
router as unknown as { stack: RouteLayer[] }
|
||||
).stack.find(
|
||||
(layer) => layer.route?.path === "/image-upload" && layer.route.methods.post,
|
||||
);
|
||||
const imageUploadHandler =
|
||||
imageUploadLayer!.route!.stack[imageUploadLayer!.route!.stack.length - 1]!
|
||||
.handle;
|
||||
|
||||
function fakeSftp(behavior: { writeError?: Error } = {}): {
|
||||
sftp: ImageSftpClient;
|
||||
written: Map<string, Buffer>;
|
||||
end: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const written = new Map<string, Buffer>();
|
||||
const end = vi.fn();
|
||||
const sftp = {
|
||||
mkdir: (
|
||||
_dir: string,
|
||||
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
|
||||
maybeCallback?: (err?: Error) => void,
|
||||
) => {
|
||||
const callback =
|
||||
typeof attrsOrCallback === "function"
|
||||
? attrsOrCallback
|
||||
: maybeCallback!;
|
||||
callback();
|
||||
},
|
||||
createWriteStream: (remotePath: string, _options?: { mode?: number }) => {
|
||||
const stream = new EventEmitter() as NodeJS.WritableStream & {
|
||||
end: (data: Buffer) => void;
|
||||
};
|
||||
stream.end = (data: Buffer) => {
|
||||
queueMicrotask(() => {
|
||||
if (behavior.writeError) {
|
||||
stream.emit("error", behavior.writeError);
|
||||
return;
|
||||
}
|
||||
written.set(remotePath, data);
|
||||
stream.emit("close");
|
||||
});
|
||||
};
|
||||
return stream;
|
||||
},
|
||||
readdir: (
|
||||
_dir: string,
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
entries: Array<{
|
||||
filename: string;
|
||||
attrs?: { size?: number; mtime?: number };
|
||||
}>,
|
||||
) => void,
|
||||
) => callback(undefined, []),
|
||||
unlink: (_path: string, callback: (error?: Error) => void) => callback(),
|
||||
rmdir: (_dir: string, callback: (error?: Error) => void) => callback(),
|
||||
end,
|
||||
} as unknown as ImageSftpClient & { end: ReturnType<typeof vi.fn> };
|
||||
return { sftp, written, end };
|
||||
}
|
||||
|
||||
function connectedSession(instanceId: string, sftp: ImageSftpClient) {
|
||||
return {
|
||||
tabInstanceId: instanceId,
|
||||
isConnected: true,
|
||||
sshConn: {
|
||||
sftp: (
|
||||
callback: (err: Error | undefined, sftp: ImageSftpClient) => void,
|
||||
) => callback(undefined, sftp),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function invoke(options: {
|
||||
file?: { buffer: Buffer; mimetype: string; size: number };
|
||||
instanceId?: string;
|
||||
metadata?: { source?: string; clientUploadTimestamp?: string };
|
||||
}) {
|
||||
const body: Record<string, string> = {};
|
||||
if (options.instanceId) body.instanceId = options.instanceId;
|
||||
if (options.metadata?.source !== undefined)
|
||||
body.source = options.metadata.source;
|
||||
if (options.metadata?.clientUploadTimestamp !== undefined)
|
||||
body.clientUploadTimestamp = options.metadata.clientUploadTimestamp;
|
||||
const req = {
|
||||
userId: state.userId,
|
||||
body,
|
||||
file: options.file,
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
const result = { statusCode: 200, body: null as unknown };
|
||||
const res = {
|
||||
status(code: number) {
|
||||
result.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body: unknown) {
|
||||
result.body = body;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response;
|
||||
await imageUploadHandler(req, res, () => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
const LEGACY_ENV_NAMES = [
|
||||
"TERMIX_IMAGE_STORAGE_MODE",
|
||||
"TERMIX_IMAGE_DIR",
|
||||
"TERMIX_IMAGE_HOST_PATH",
|
||||
"TERMIX_IMAGE_TTL_MS",
|
||||
"TERMIX_MAX_IMAGE_COUNT",
|
||||
"TERMIX_MAX_IMAGE_STORAGE_BYTES",
|
||||
"DATA_DIR",
|
||||
];
|
||||
|
||||
let pngBuffer: Buffer;
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
|
||||
beforeAll(async () => {
|
||||
pngBuffer = await sharp({
|
||||
create: { width: 2, height: 2, channels: 3, background: "#ffffff" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
state.settings = {};
|
||||
state.sessions = [];
|
||||
savedEnv = Object.fromEntries(
|
||||
LEGACY_ENV_NAMES.map((name) => [name, process.env[name]]),
|
||||
);
|
||||
for (const name of LEGACY_ENV_NAMES) delete process.env[name];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of LEGACY_ENV_NAMES) {
|
||||
if (savedEnv[name] === undefined) delete process.env[name];
|
||||
else process.env[name] = savedEnv[name];
|
||||
}
|
||||
});
|
||||
|
||||
describe("terminal image upload route", () => {
|
||||
it("rejects requests without an image file", async () => {
|
||||
const response = await invoke({ instanceId: "tab-1" });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({ code: "IMAGE_FILE_MISSING" });
|
||||
});
|
||||
|
||||
it("requires a connected terminal in explicit remote-sftp mode", async () => {
|
||||
state.settings["terminal_image_storage_mode"] = "remote-sftp";
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_TERMINAL_NOT_CONNECTED",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes over SFTP in auto mode when a session is connected", async () => {
|
||||
const { sftp, written, end } = fakeSftp();
|
||||
state.sessions = [connectedSession("tab-1", sftp)];
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.body as {
|
||||
id: string;
|
||||
filename: string;
|
||||
shellPath: string;
|
||||
storage: string;
|
||||
};
|
||||
expect(body.storage).toBe("remote-sftp");
|
||||
expect(body.filename).toBe(`${body.id}.png`);
|
||||
expect(body.shellPath).toBe(`/tmp/termix-images/${body.filename}`);
|
||||
// Sharp normalized the upload to PNG before the write.
|
||||
const writtenBytes = written.get(body.shellPath)!;
|
||||
expect(writtenBytes.subarray(1, 4).toString()).toBe("PNG");
|
||||
expect(end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("maps SFTP failures to 502 without leaking the raw remote error", async () => {
|
||||
const { sftp, end } = fakeSftp({
|
||||
writeError: new Error("Permission denied"),
|
||||
});
|
||||
state.sessions = [connectedSession("tab-1", sftp)];
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.body).toEqual({
|
||||
error: "Failed to write image to the remote host",
|
||||
code: "IMAGE_REMOTE_WRITE_FAILED",
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain("Permission denied");
|
||||
expect(end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects undecodable image data", async () => {
|
||||
const { sftp } = fakeSftp();
|
||||
state.sessions = [connectedSession("tab-1", sftp)];
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: Buffer.from("definitely not an image"),
|
||||
mimetype: "image/png",
|
||||
size: 23,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toMatchObject({ code: "IMAGE_DECODE_FAILED" });
|
||||
});
|
||||
|
||||
describe("local mapped storage", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// The settings validator rejects backslashes, so store the temp dir in
|
||||
// POSIX form. Windows still resolves it for the real file checks.
|
||||
dir = (
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "termix-route-test-"))
|
||||
).replace(/\\/g, "/");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stores locally without a terminal session and hides backend paths", async () => {
|
||||
state.settings["terminal_image_storage_mode"] = "local";
|
||||
state.settings["terminal_image_local_dir"] = dir;
|
||||
state.settings["terminal_image_host_path"] = "/host-view/images";
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.body as {
|
||||
id: string;
|
||||
filename: string;
|
||||
shellPath: string;
|
||||
storage: string;
|
||||
};
|
||||
expect(body.storage).toBe("local");
|
||||
expect(body.shellPath).toBe(`/host-view/images/${body.filename}`);
|
||||
expect(JSON.stringify(body)).not.toContain(dir);
|
||||
const stored = await fs.readFile(path.join(dir, body.filename));
|
||||
expect(stored.subarray(1, 4).toString()).toBe("PNG");
|
||||
});
|
||||
|
||||
it("answers 507 when the configured image count cap is reached", async () => {
|
||||
state.settings["terminal_image_storage_mode"] = "local";
|
||||
state.settings["terminal_image_local_dir"] = dir;
|
||||
state.settings["terminal_image_host_path"] = "/host-view/images";
|
||||
state.settings["terminal_image_max_count"] = "1";
|
||||
await fs.writeFile(
|
||||
path.join(dir, `${randomUUID()}.png`),
|
||||
Buffer.alloc(16),
|
||||
);
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(507);
|
||||
expect(response.body).toMatchObject({
|
||||
error: "Image storage limit reached",
|
||||
code: "IMAGE_STORAGE_LIMIT_REACHED",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy explicit local mappings on local mode", async () => {
|
||||
process.env.TERMIX_IMAGE_DIR = dir;
|
||||
process.env.TERMIX_IMAGE_HOST_PATH = "/host-view/images";
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toMatchObject({ storage: "local" });
|
||||
});
|
||||
|
||||
it("returns 503 when local storage inspection fails", async () => {
|
||||
const blocked = `${dir}/blocked-file`;
|
||||
await fs.writeFile(blocked, "not a directory");
|
||||
state.settings["terminal_image_storage_mode"] = "local";
|
||||
state.settings["terminal_image_local_dir"] = blocked;
|
||||
state.settings["terminal_image_host_path"] = "/host-view/images";
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.body).toEqual({
|
||||
error: "Unable to inspect local image storage",
|
||||
code: "IMAGE_LOCAL_INSPECTION_FAILED",
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain(blocked);
|
||||
});
|
||||
it("rejects auto mode when no verified storage capability is available", async () => {
|
||||
process.env.DATA_DIR = dir;
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-missing",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "IMAGE_STORAGE_UNAVAILABLE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagnostic metadata", () => {
|
||||
interface UploadLogMeta {
|
||||
operation?: string;
|
||||
requestId?: string;
|
||||
sequence?: number;
|
||||
source?: string;
|
||||
clientUploadTimestamp?: string;
|
||||
serverReceivedAt?: string;
|
||||
bytes?: number;
|
||||
}
|
||||
|
||||
function uploadLogEntries(): UploadLogMeta[] {
|
||||
return vi
|
||||
.mocked(databaseLogger.info)
|
||||
.mock.calls.filter(
|
||||
(call) =>
|
||||
(call[1] as UploadLogMeta | undefined)?.operation ===
|
||||
"terminal_image_upload_received",
|
||||
)
|
||||
.map((call) => call[1] as UploadLogMeta);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(databaseLogger.info).mockClear();
|
||||
});
|
||||
|
||||
it("logs correlation id, receipt time, and propagated source metadata", async () => {
|
||||
const { sftp } = fakeSftp();
|
||||
state.sessions = [connectedSession("tab-1", sftp)];
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
metadata: {
|
||||
source: "clipboard",
|
||||
clientUploadTimestamp: "2026-08-15T12:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const entries = uploadLogEntries();
|
||||
expect(entries).toHaveLength(1);
|
||||
const meta = entries[0]!;
|
||||
expect(meta.requestId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
expect(typeof meta.sequence).toBe("number");
|
||||
expect(meta.source).toBe("clipboard");
|
||||
expect(meta.clientUploadTimestamp).toBe("2026-08-15T12:00:00.000Z");
|
||||
expect(Number.isNaN(Date.parse(meta.serverReceivedAt ?? ""))).toBe(false);
|
||||
expect(meta.bytes).toBe(pngBuffer.length);
|
||||
});
|
||||
|
||||
it("logs a monotonically increasing upload sequence", async () => {
|
||||
const { sftp } = fakeSftp();
|
||||
state.sessions = [connectedSession("tab-1", sftp)];
|
||||
|
||||
await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
metadata: { source: "file" },
|
||||
});
|
||||
await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
instanceId: "tab-1",
|
||||
metadata: { source: "clipboard" },
|
||||
});
|
||||
|
||||
const sequences = uploadLogEntries().map((entry) => entry.sequence!);
|
||||
expect(sequences).toHaveLength(2);
|
||||
expect(sequences[1]).toBe(sequences[0]! + 1);
|
||||
});
|
||||
|
||||
it("accepts missing metadata and keeps raw paths out of the log", async () => {
|
||||
const dir = (
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), "termix-meta-test-"))
|
||||
).replace(/\\/g, "/");
|
||||
try {
|
||||
state.settings["terminal_image_storage_mode"] = "local";
|
||||
state.settings["terminal_image_local_dir"] = dir;
|
||||
state.settings["terminal_image_host_path"] = "/host-view/images";
|
||||
|
||||
const response = await invoke({
|
||||
file: {
|
||||
buffer: pngBuffer,
|
||||
mimetype: "image/png",
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const entries = uploadLogEntries();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]!.source).toBeUndefined();
|
||||
expect(entries[0]!.clientUploadTimestamp).toBeUndefined();
|
||||
expect(JSON.stringify(entries[0])).not.toContain(dir);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
exceedsImageStorageLimit,
|
||||
exceedsNormalizedImageSize,
|
||||
imageExtensionForFormat,
|
||||
createConcurrencyLimiter,
|
||||
isExpiredImage,
|
||||
isImageFilename,
|
||||
} from "../../../database/routes/terminal-image-utils.js";
|
||||
|
||||
describe("terminal image utilities", () => {
|
||||
it("accepts UUID-based image filenames", () => {
|
||||
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506.jpeg")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe or unrelated filenames", () => {
|
||||
expect(isImageFilename("../secrets.txt")).toBe(false);
|
||||
expect(isImageFilename("not-an-image.jpeg")).toBe(false);
|
||||
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps decoded raster formats while excluding SVG", () => {
|
||||
expect(imageExtensionForFormat("jpeg")).toBe("jpg");
|
||||
expect(imageExtensionForFormat("heif")).toBe("heif");
|
||||
expect(imageExtensionForFormat("tiff")).toBe("tiff");
|
||||
expect(imageExtensionForFormat("svg")).toBeUndefined();
|
||||
expect(imageExtensionForFormat(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects normalized output beyond the byte ceiling", () => {
|
||||
expect(exceedsNormalizedImageSize(10_000_001, 10_000_000)).toBe(true);
|
||||
expect(exceedsNormalizedImageSize(10_000_000, 10_000_000)).toBe(false);
|
||||
});
|
||||
it("expires files older than the configured TTL", () => {
|
||||
expect(isExpiredImage(1_000, 3_000, 1_000)).toBe(true);
|
||||
expect(isExpiredImage(2_500, 3_000, 1_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a zero TTL as retention disabled", () => {
|
||||
expect(isExpiredImage(1_000, 999_999, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds the admission queue", async () => {
|
||||
const limiter = createConcurrencyLimiter(1, 0);
|
||||
const release = await limiter.acquire();
|
||||
await expect(limiter.acquire()).rejects.toThrow("queue is full");
|
||||
release();
|
||||
});
|
||||
|
||||
it("rejects uploads that exceed the count or byte limit", () => {
|
||||
expect(exceedsImageStorageLimit(100, 10, 1, 100, 1000)).toBe(true);
|
||||
expect(exceedsImageStorageLimit(1, 900, 101, 100, 1000)).toBe(true);
|
||||
expect(exceedsImageStorageLimit(1, 900, 100, 100, 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds concurrent work", async () => {
|
||||
const limiter = createConcurrencyLimiter(1);
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const task = async () => {
|
||||
const release = await limiter.acquire();
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
release();
|
||||
};
|
||||
|
||||
await Promise.all([task(), task(), task()]);
|
||||
|
||||
expect(peak).toBe(1);
|
||||
expect(limiter.active).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import speakeasy from "speakeasy";
|
||||
|
||||
const userUpdate = vi.fn().mockResolvedValue(null);
|
||||
const findById = vi.fn();
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({ findById, update: userUpdate }),
|
||||
createCurrentTrustedDeviceRepository: () => ({}),
|
||||
createCurrentUserSessionRepository: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn().mockResolvedValue(undefined) },
|
||||
}));
|
||||
|
||||
const { registerUserTotpRoutes } =
|
||||
await import("../../../database/routes/user-totp-routes.js");
|
||||
|
||||
const secret = speakeasy.generateSecret({ name: "test" }).base32;
|
||||
const PASSWORD = "correct-horse";
|
||||
|
||||
/**
|
||||
* The disable dialog has one field, labelled "Enter TOTP code or password",
|
||||
* and its caller passes that single value as `disableTOTP(input)` — which
|
||||
* lands in the `password` argument, leaving `totp_code` undefined.
|
||||
*
|
||||
* 2.5.1 changed the route to require both, so from then on the first check
|
||||
* rejected every attempt regardless of what was typed. Nobody could turn 2FA
|
||||
* off, and the client reported it as the generic "Failed to disable 2FA".
|
||||
*/
|
||||
describe("POST /totp/disable", () => {
|
||||
let handler: (req: unknown, res: unknown) => Promise<void>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const routes = new Map<string, (req: unknown, res: unknown) => unknown>();
|
||||
const router = {
|
||||
post: (path: string, ...rest: unknown[]) => {
|
||||
routes.set(path, rest[rest.length - 1] as never);
|
||||
},
|
||||
get: () => {},
|
||||
put: () => {},
|
||||
delete: () => {},
|
||||
};
|
||||
|
||||
registerUserTotpRoutes(
|
||||
router as never,
|
||||
{
|
||||
authenticateJWT: (() => {}) as never,
|
||||
authManager: { getUserDataKey: () => null } as never,
|
||||
isNativeAppRequest: () => false,
|
||||
} as never,
|
||||
);
|
||||
|
||||
handler = routes.get("/totp/disable") as never;
|
||||
findById.mockResolvedValue({
|
||||
id: "user-1",
|
||||
isOidc: false,
|
||||
passwordHash: bcrypt.hashSync(PASSWORD, 4),
|
||||
totpSecret: secret,
|
||||
totpBackupCodes: JSON.stringify(["BACKUP01"]),
|
||||
totpEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
function call(body: Record<string, unknown>) {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.body = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return handler({ userId: "user-1", body }, res).then(() => res);
|
||||
}
|
||||
|
||||
it("accepts the TOTP code on its own", async () => {
|
||||
// What the dialog sends: one value, in whichever field the client used.
|
||||
const res = await call({
|
||||
totp_code: speakeasy.totp({ secret, encoding: "base32" }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(userUpdate).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({ totpEnabled: false, totpSecret: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the account password on its own", async () => {
|
||||
// The single field is labelled "TOTP code or password", and the client
|
||||
// passes it as the password argument — this is the exact failing call.
|
||||
const res = await call({ password: PASSWORD });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(userUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a backup code", async () => {
|
||||
const res = await call({ totp_code: "BACKUP01" });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("still refuses a wrong value", async () => {
|
||||
const res = await call({ password: "not-my-password" });
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an empty request rather than disabling anything", async () => {
|
||||
const res = await call({});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let an OIDC user disable it with a password", async () => {
|
||||
// No password to compare against; only a TOTP or backup code will do.
|
||||
findById.mockResolvedValue({
|
||||
id: "user-1",
|
||||
isOidc: true,
|
||||
passwordHash: null,
|
||||
totpSecret: secret,
|
||||
totpBackupCodes: JSON.stringify([]),
|
||||
totpEnabled: true,
|
||||
});
|
||||
|
||||
const res = await call({ password: "anything" });
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, RequestHandler, Response } from "express";
|
||||
import { TOUCH_INPUT_DEFAULTS } from "../../../../types/touch-input-settings.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "admin",
|
||||
admins: new Set(["admin"]),
|
||||
stored: null as string | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: { error: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => ({ id, isAdmin: state.admins.has(id) }),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async () => state.stored,
|
||||
set: async (_key: string, value: string) => {
|
||||
state.stored = value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { registerTouchInputSettingsRoutes } =
|
||||
await import("../../../database/routes/touch-input-settings-routes.js");
|
||||
|
||||
type Registered = { method: string; path: string; handler: RequestHandler };
|
||||
const registered: Registered[] = [];
|
||||
const router = {
|
||||
get: (path: string, ...handlers: RequestHandler[]) =>
|
||||
registered.push({ method: "get", path, handler: handlers.at(-1)! }),
|
||||
patch: (path: string, ...handlers: RequestHandler[]) =>
|
||||
registered.push({ method: "patch", path, handler: handlers.at(-1)! }),
|
||||
} as unknown as import("express").Router;
|
||||
registerTouchInputSettingsRoutes(router, (_req, _res, next) => next());
|
||||
|
||||
async function invoke(method: string, body: unknown = {}) {
|
||||
const handler = registered.find((entry) => entry.method === method)!.handler;
|
||||
const req = { userId: state.userId, body } as unknown as Request;
|
||||
const result = { statusCode: 200, body: null as unknown };
|
||||
const res = {
|
||||
status(code: number) {
|
||||
result.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(bodyValue: unknown) {
|
||||
result.body = bodyValue;
|
||||
return this;
|
||||
},
|
||||
} as Response;
|
||||
await handler(req, res, () => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.userId = "admin";
|
||||
state.stored = null;
|
||||
});
|
||||
|
||||
describe("touch input settings routes", () => {
|
||||
it("allows authenticated non-admin users to read normalized defaults", async () => {
|
||||
state.userId = "user";
|
||||
const response = await invoke("get");
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual(TOUCH_INPUT_DEFAULTS);
|
||||
});
|
||||
|
||||
it("only allows admins to write", async () => {
|
||||
state.userId = "user";
|
||||
const response = await invoke("patch", { enabled: false });
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(state.stored).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects out-of-range values and persists normalized updates", async () => {
|
||||
const invalid = await invoke("patch", { maximumTicksPerFrame: 101 });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
|
||||
const valid = await invoke("patch", {
|
||||
dragThresholdPx: 9,
|
||||
momentumEnabled: false,
|
||||
});
|
||||
expect(valid.statusCode).toBe(200);
|
||||
expect(JSON.parse(state.stored!)).toEqual({
|
||||
...TOUCH_INPUT_DEFAULTS,
|
||||
dragThresholdPx: 9,
|
||||
momentumEnabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,28 @@ vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => [...state.users.values()],
|
||||
listPage: async ({
|
||||
search,
|
||||
limit,
|
||||
offset,
|
||||
}: {
|
||||
search?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}) => {
|
||||
const term = search?.trim().toLowerCase();
|
||||
const matched = [...state.users.values()]
|
||||
.filter((u) => !term || u.username?.toLowerCase().includes(term))
|
||||
.sort((a, b) =>
|
||||
(a.username ?? "").localeCompare(b.username ?? "", undefined, {
|
||||
sensitivity: "base",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
users: matched.slice(offset, offset + limit),
|
||||
total: matched.length,
|
||||
};
|
||||
},
|
||||
findById: async (id: string) => state.users.get(id) ?? null,
|
||||
findByUsername: async (username: string) =>
|
||||
[...state.users.values()].find((u) => u.username === username) ?? null,
|
||||
@@ -102,11 +124,13 @@ function findHandler(method: string, path: string): RequestHandler {
|
||||
function makeReqRes(overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
}) {
|
||||
const req = {
|
||||
userId: state.currentUserId,
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
query: overrides.query ?? {},
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
|
||||
@@ -142,6 +166,7 @@ async function invoke(
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const handler = findHandler(method, path);
|
||||
@@ -214,6 +239,64 @@ describe("GET /list", () => {
|
||||
expect(users[0].data_unlocked).toBeUndefined();
|
||||
expect(users[0].totp_enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns every user when no limit is given", async () => {
|
||||
// The share pickers depend on this: they fetch once and filter locally.
|
||||
const res = await invoke("get", "/list");
|
||||
const body = res.jsonBody as {
|
||||
users: unknown[];
|
||||
limit?: number;
|
||||
total: number;
|
||||
};
|
||||
expect(body.users).toHaveLength(3);
|
||||
expect(body.total).toBe(3);
|
||||
expect(body.limit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns one page and the full total when a limit is given", async () => {
|
||||
const res = await invoke("get", "/list", { query: { limit: "2" } });
|
||||
const body = res.jsonBody as {
|
||||
users: unknown[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
expect(body.users).toHaveLength(2);
|
||||
expect(body.total).toBe(3);
|
||||
expect(body.limit).toBe(2);
|
||||
expect(body.offset).toBe(0);
|
||||
});
|
||||
|
||||
it("pages with an offset", async () => {
|
||||
const res = await invoke("get", "/list", {
|
||||
query: { limit: "2", offset: "2" },
|
||||
});
|
||||
const body = res.jsonBody as { users: unknown[]; total: number };
|
||||
expect(body.users).toHaveLength(1);
|
||||
expect(body.total).toBe(3);
|
||||
});
|
||||
|
||||
it("filters by search term without a limit", async () => {
|
||||
const res = await invoke("get", "/list", { query: { search: "lock" } });
|
||||
const body = res.jsonBody as {
|
||||
users: { username: string }[];
|
||||
total: number;
|
||||
};
|
||||
expect(body.users.map((u) => u.username)).toEqual(["locked"]);
|
||||
expect(body.total).toBe(1);
|
||||
});
|
||||
|
||||
it("caps an oversized page size", async () => {
|
||||
const res = await invoke("get", "/list", { query: { limit: "100000" } });
|
||||
expect((res.jsonBody as { limit: number }).limit).toBe(500);
|
||||
});
|
||||
|
||||
it("ignores a non-numeric limit and returns the full list", async () => {
|
||||
const res = await invoke("get", "/list", { query: { limit: "abc" } });
|
||||
const body = res.jsonBody as { users: unknown[]; limit?: number };
|
||||
expect(body.users).toHaveLength(3);
|
||||
expect(body.limit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /admin/totp/disable", () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
isOIDCUserAllowed,
|
||||
getOIDCConfigFromEnv,
|
||||
extractOidcGroups,
|
||||
extractOidcGroupsFromSources,
|
||||
validateLogoutTokenClaims,
|
||||
parseOidcRoleMap,
|
||||
resolveOidcMappedRoles,
|
||||
@@ -126,6 +127,46 @@ describe("verifyOIDCToken JWKS diagnostics", () => {
|
||||
});
|
||||
|
||||
describe("verifyOIDCToken", () => {
|
||||
it("accepts a discovery document URL as the configured issuer", async () => {
|
||||
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||
const jwk = await exportJWK(publicKey);
|
||||
jwk.kid = "google-key";
|
||||
|
||||
const issuer = "https://accounts.google.com";
|
||||
const clientId = "termix-client";
|
||||
const token = await new SignJWT({ sub: "user-1" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
|
||||
.setIssuer(issuer)
|
||||
.setAudience(clientId)
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey);
|
||||
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ jwks_uri: `${issuer}/keys` }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const payload = await verifyOIDCToken(
|
||||
token,
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
clientId,
|
||||
);
|
||||
|
||||
expect(payload.sub).toBe("user-1");
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the protected-header algorithm when the provider JWK omits alg", async () => {
|
||||
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||
@@ -345,6 +386,35 @@ describe("extractOidcGroups", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractOidcGroupsFromSources", () => {
|
||||
it("preserves ID token groups when userinfo omits them", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources([
|
||||
{ groups: ["admins", "users"] },
|
||||
{ sub: "user-1", name: "Example User" },
|
||||
]),
|
||||
).toEqual(["admins", "users"]);
|
||||
});
|
||||
|
||||
it("combines and deduplicates groups from both verified sources", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources([
|
||||
{ roles: ["users", "operators"] },
|
||||
{ roles: ["operators", "admins"] },
|
||||
]),
|
||||
).toEqual(["users", "operators", "admins"]);
|
||||
});
|
||||
|
||||
it("supports a configured group claim across sources", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources(
|
||||
[{ custom_groups: ["admins"] }, { custom_groups: ["users"] }],
|
||||
"custom_groups",
|
||||
),
|
||||
).toEqual(["admins", "users"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateLogoutTokenClaims", () => {
|
||||
const validClaims = {
|
||||
sub: "subject-1",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateDefaultsJson } from "../../../database/routes/user-preferences.js";
|
||||
|
||||
describe("user connection defaults validation", () => {
|
||||
it("accepts JSON objects", () => {
|
||||
expect(validateDefaultsJson('{"fontSize":16}')).toBe(true);
|
||||
expect(validateDefaultsJson("{}")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed JSON and non-object values", () => {
|
||||
expect(validateDefaultsJson("{")).toBe(false);
|
||||
expect(validateDefaultsJson("null")).toBe(false);
|
||||
expect(validateDefaultsJson("[]")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects payloads larger than 32 KiB", () => {
|
||||
expect(
|
||||
validateDefaultsJson(JSON.stringify({ value: "x".repeat(32_768) })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Request, Response, Router } from "express";
|
||||
|
||||
type WorkspaceRow = {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
kind: "manual" | "last_session";
|
||||
isDefault: boolean;
|
||||
payload: string;
|
||||
syncId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "user-1",
|
||||
workspaces: new Map<number, WorkspaceRow>(),
|
||||
nextId: 1,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function findByIdForUser(userId: string, id: number): WorkspaceRow | null {
|
||||
const row = state.workspaces.get(id);
|
||||
return row && row.userId === userId ? row : null;
|
||||
}
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentWorkspaceRepository: () => ({
|
||||
listByUser: async (userId: string) =>
|
||||
[...state.workspaces.values()].filter((w) => w.userId === userId),
|
||||
findById: async (userId: string, id: number) => findByIdForUser(userId, id),
|
||||
findLastSession: async (userId: string) =>
|
||||
[...state.workspaces.values()].find(
|
||||
(w) => w.userId === userId && w.kind === "last_session",
|
||||
) ?? null,
|
||||
upsertLastSession: async (userId: string, payload: string) => {
|
||||
const existing = [...state.workspaces.values()].find(
|
||||
(w) => w.userId === userId && w.kind === "last_session",
|
||||
);
|
||||
if (existing) {
|
||||
existing.payload = payload;
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
return existing;
|
||||
}
|
||||
const row: WorkspaceRow = {
|
||||
id: state.nextId++,
|
||||
userId,
|
||||
name: "Last Session",
|
||||
color: null,
|
||||
icon: null,
|
||||
kind: "last_session",
|
||||
isDefault: false,
|
||||
payload,
|
||||
syncId: `sync-${state.nextId}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
};
|
||||
state.workspaces.set(row.id, row);
|
||||
return row;
|
||||
},
|
||||
create: async (
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: string;
|
||||
},
|
||||
) => {
|
||||
const row: WorkspaceRow = {
|
||||
id: state.nextId++,
|
||||
userId,
|
||||
name: input.name,
|
||||
color: input.color ?? null,
|
||||
icon: input.icon ?? null,
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
payload: input.payload,
|
||||
syncId: `sync-${state.nextId}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
};
|
||||
state.workspaces.set(row.id, row);
|
||||
return row;
|
||||
},
|
||||
update: async (
|
||||
userId: string,
|
||||
id: number,
|
||||
input: { name?: string; color?: string | null; icon?: string | null },
|
||||
) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
if (input.name !== undefined) row.name = input.name;
|
||||
if (input.color !== undefined) row.color = input.color;
|
||||
if (input.icon !== undefined) row.icon = input.icon;
|
||||
return row;
|
||||
},
|
||||
updateContent: async (userId: string, id: number, payload: string) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
row.payload = payload;
|
||||
return row;
|
||||
},
|
||||
setDefault: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
for (const w of state.workspaces.values()) {
|
||||
if (w.userId === userId && w.kind === "manual") w.isDefault = false;
|
||||
}
|
||||
row.isDefault = true;
|
||||
return row;
|
||||
},
|
||||
unsetDefault: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
row.isDefault = false;
|
||||
return row;
|
||||
},
|
||||
touchLastUsed: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (row) row.lastUsedAt = new Date().toISOString();
|
||||
},
|
||||
delete: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return false;
|
||||
state.workspaces.delete(id);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/workspaces.js");
|
||||
|
||||
function findLayer(method: string, path: string) {
|
||||
const stack = (router as unknown as Router).stack as Array<{
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
|
||||
};
|
||||
}>;
|
||||
const layer = stack.find(
|
||||
(l) => l.route?.path === path && l.route?.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
return layer.route.stack[layer.route.stack.length - 1].handle;
|
||||
}
|
||||
|
||||
function makeReqRes(overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
}) {
|
||||
const req = {
|
||||
userId: state.currentUserId,
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
(this as unknown as { statusCode: number }).statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const handler = findLayer(method, path);
|
||||
const { req, res } = makeReqRes(overrides);
|
||||
await handler(req, res);
|
||||
return res as unknown as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "user-1";
|
||||
state.workspaces = new Map();
|
||||
state.nextId = 1;
|
||||
});
|
||||
|
||||
describe("GET /", () => {
|
||||
it("returns the list with a computed tabCount", async () => {
|
||||
await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Test A",
|
||||
payload: { version: 1, tabs: [{ slotId: "a" }, { slotId: "b" }] },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await invoke("get", "/");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toHaveLength(1);
|
||||
expect(
|
||||
(res.jsonBody as unknown as { tabCount: number }[])[0].tabCount,
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /", () => {
|
||||
it("400s when name is missing", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("400s when payload has no tabs array", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { name: "Test A", payload: { version: 1 } },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("200s and creates a manual workspace", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { name: "Test A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ name: "Test A", kind: "manual" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /:id", () => {
|
||||
it("rejects renaming the last_session row", async () => {
|
||||
const created = await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("patch", "/:id", {
|
||||
params: { id: String(id) },
|
||||
body: { name: "Nope" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("renames a manual workspace", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "Old", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("patch", "/:id", {
|
||||
params: { id: String(id) },
|
||||
body: { name: "New" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ name: "New" });
|
||||
});
|
||||
|
||||
it("404s for a nonexistent id", async () => {
|
||||
const res = await invoke("patch", "/:id", {
|
||||
params: { id: "999" },
|
||||
body: { name: "New" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /:id/content", () => {
|
||||
it("updates payload for a manual workspace", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("put", "/:id/content", {
|
||||
params: { id: String(id) },
|
||||
body: { payload: { version: 1, tabs: [{ slotId: "x" }] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ tabCount: 1 });
|
||||
});
|
||||
|
||||
it("404s on wrong owner", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
state.currentUserId = "user-2";
|
||||
const res = await invoke("put", "/:id/content", {
|
||||
params: { id: String(id) },
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /:id", () => {
|
||||
it("rejects deleting the last_session row", async () => {
|
||||
const created = await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("delete", "/:id", { params: { id: String(id) } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("404s for a nonexistent id", async () => {
|
||||
const res = await invoke("delete", "/:id", { params: { id: "999" } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("deletes a manual workspace", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("delete", "/:id", { params: { id: String(id) } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/duplicate", () => {
|
||||
it("produces a second independent row", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "A",
|
||||
payload: { version: 1, tabs: [{ slotId: "a" }] },
|
||||
},
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const dup = await invoke("post", "/:id/duplicate", {
|
||||
params: { id: String(id) },
|
||||
body: { name: "A (copy)" },
|
||||
});
|
||||
expect(dup.statusCode).toBe(200);
|
||||
expect(dup.jsonBody).toMatchObject({ name: "A (copy)", tabCount: 1 });
|
||||
|
||||
const updateOriginal = await invoke("put", "/:id/content", {
|
||||
params: { id: String(id) },
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(updateOriginal.jsonBody).toMatchObject({ tabCount: 0 });
|
||||
|
||||
const dupId = (dup.jsonBody as unknown as { id: number }).id;
|
||||
const refetched = await invoke("get", "/");
|
||||
const dupRow = (
|
||||
refetched.jsonBody as unknown as { id: number; tabCount: number }[]
|
||||
).find((w) => w.id === dupId);
|
||||
expect(dupRow?.tabCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/set-default", () => {
|
||||
it("clears a previous default and sets the new one", async () => {
|
||||
const a = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const b = await invoke("post", "/", {
|
||||
body: { name: "B", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const aId = (a.jsonBody as unknown as { id: number }).id;
|
||||
const bId = (b.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
|
||||
const second = await invoke("post", "/:id/set-default", {
|
||||
params: { id: String(bId) },
|
||||
});
|
||||
expect(second.jsonBody).toMatchObject({ isDefault: true });
|
||||
|
||||
const list = await invoke("get", "/");
|
||||
const aRow = (
|
||||
list.jsonBody as unknown as { id: number; isDefault: boolean }[]
|
||||
).find((w) => w.id === aId);
|
||||
expect(aRow?.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent when re-called on an already-default row", async () => {
|
||||
const a = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const aId = (a.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
|
||||
const again = await invoke("post", "/:id/set-default", {
|
||||
params: { id: String(aId) },
|
||||
});
|
||||
expect(again.statusCode).toBe(200);
|
||||
expect(again.jsonBody).toMatchObject({ isDefault: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/unset-default", () => {
|
||||
it("clears isDefault on a manual workspace", async () => {
|
||||
const a = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const aId = (a.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
|
||||
const res = await invoke("post", "/:id/unset-default", {
|
||||
params: { id: String(aId) },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ isDefault: false });
|
||||
});
|
||||
|
||||
it("rejects unsetting the last_session row", async () => {
|
||||
const created = await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("post", "/:id/unset-default", {
|
||||
params: { id: String(id) },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/apply", () => {
|
||||
it("touches lastUsedAt and returns the parsed payload", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [{ slotId: "a" }] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("post", "/:id/apply", {
|
||||
params: { id: String(id) },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
payload: { version: 1, tabs: [{ slotId: "a" }] },
|
||||
});
|
||||
expect(
|
||||
(res.jsonBody as unknown as { lastUsedAt: string | null }).lastUsedAt,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /last-session and GET /last-session", () => {
|
||||
it("returns null before any save", async () => {
|
||||
const res = await invoke("get", "/last-session");
|
||||
expect(res.jsonBody).toBeNull();
|
||||
});
|
||||
|
||||
it("upserts idempotently - two calls produce one row", async () => {
|
||||
await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [{ slotId: "a" }] } },
|
||||
});
|
||||
|
||||
const list = await invoke("get", "/");
|
||||
const lastSessionRows = (
|
||||
list.jsonBody as unknown as { kind: string }[]
|
||||
).filter((w) => w.kind === "last_session");
|
||||
expect(lastSessionRows).toHaveLength(1);
|
||||
|
||||
const res = await invoke("get", "/last-session");
|
||||
expect(res.jsonBody).toMatchObject({ tabCount: 1 });
|
||||
});
|
||||
});
|
||||
@@ -101,4 +101,62 @@ describe("expandOidcUsername", () => {
|
||||
"$oidc.preferred_username",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips the ldap:{providerId}: prefix for genuine LDAP users", async () => {
|
||||
vi.doMock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({
|
||||
oidcIdentifier: "ldap:1:jdoe",
|
||||
ssoProviderId: 1,
|
||||
}),
|
||||
}),
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
findById: async () => ({ type: "ldap" }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
|
||||
});
|
||||
|
||||
it("does not strip a spoofed ldap: identifier from a non-LDAP provider", async () => {
|
||||
vi.doMock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({
|
||||
oidcIdentifier: "ldap:1:admin",
|
||||
ssoProviderId: 1,
|
||||
}),
|
||||
}),
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
findById: async () => ({ type: "oidc" }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"ldap:1:admin",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not strip when the embedded provider id is not the user's provider", async () => {
|
||||
vi.doMock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({
|
||||
oidcIdentifier: "ldap:1:admin",
|
||||
ssoProviderId: 5,
|
||||
}),
|
||||
}),
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
findById: async () => ({ type: "ldap" }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
await import("../../hosts/credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"ldap:1:admin",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const setupCACertAuth = vi.fn();
|
||||
const warn = vi.fn();
|
||||
|
||||
vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({
|
||||
setupCACertAuth: (...args: unknown[]) => setupCACertAuth(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
fileLogger: { warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const { applyCACertIfPresent } =
|
||||
await import("../../../hosts/file-manager/ca-cert-auth.js");
|
||||
|
||||
/**
|
||||
* `setupCACertAuth` had no call site in the file manager at all, while its
|
||||
* sibling `setupOPKSSHCertAuth` had two. So a host whose key is paired with a
|
||||
* user-managed CA-signed certificate authenticated in the terminal and failed
|
||||
* over SFTP, and OPKSSH certificates — going through the other helper — worked
|
||||
* in both places.
|
||||
*/
|
||||
describe("applyCACertIfPresent", () => {
|
||||
const client = {} as never;
|
||||
const key = Buffer.from("private-key");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("attaches a certificate the host carries", async () => {
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
await applyCACertIfPresent(
|
||||
config,
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey: "ssh-rsa-cert-v01@openssh.com AAAA" },
|
||||
"root",
|
||||
"passphrase",
|
||||
);
|
||||
|
||||
expect(setupCACertAuth).toHaveBeenCalledWith(
|
||||
config,
|
||||
client,
|
||||
key,
|
||||
"ssh-rsa-cert-v01@openssh.com AAAA",
|
||||
"root",
|
||||
"passphrase",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when there is no certificate", async () => {
|
||||
// Most hosts. Touching the connection here would change key-only auth.
|
||||
for (const certPublicKey of [undefined, null, "", " "]) {
|
||||
await applyCACertIfPresent(
|
||||
{},
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey },
|
||||
"root",
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
expect(setupCACertAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the connection usable when the certificate is unusable", async () => {
|
||||
// The private key on its own may still be accepted — which is what
|
||||
// happened before this was wired up. Failing the connection here would
|
||||
// turn a working setup into a broken one.
|
||||
setupCACertAuth.mockRejectedValueOnce(new Error("bad cert format"));
|
||||
|
||||
await expect(
|
||||
applyCACertIfPresent(
|
||||
{},
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey: "garbage" },
|
||||
"root",
|
||||
undefined,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("CA certificate setup failed"),
|
||||
expect.objectContaining({ operation: "sftp_ca_cert_auth_failed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDirectProbeCommand,
|
||||
buildDirectRsyncCommand,
|
||||
quoteShell,
|
||||
shouldBenchmarkDirectTransfer,
|
||||
shouldUseDirectTransfer,
|
||||
} from "../../../hosts/file-manager/direct-transfer-routing.js";
|
||||
|
||||
const endpoint = { host: "10.0.0.2", port: 2222, username: "deploy" };
|
||||
|
||||
describe("direct transfer routing", () => {
|
||||
it("keeps small transfers on the relay without benchmarking", () => {
|
||||
expect(shouldBenchmarkDirectTransfer(32 * 1024 * 1024 - 1)).toBe(false);
|
||||
expect(shouldBenchmarkDirectTransfer(32 * 1024 * 1024)).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a meaningful speed advantage", () => {
|
||||
expect(shouldUseDirectTransfer(700, 1000)).toBe(true);
|
||||
expect(shouldUseDirectTransfer(850, 1000)).toBe(false);
|
||||
expect(shouldUseDirectTransfer(0, 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it("probes without accepting passwords or unknown host keys", () => {
|
||||
const command = buildDirectProbeCommand(endpoint);
|
||||
expect(command).toContain("BatchMode=yes");
|
||||
expect(command).toContain("StrictHostKeyChecking=yes");
|
||||
expect(command).toContain("ConnectTimeout=5");
|
||||
expect(command).toContain("-p 2222");
|
||||
});
|
||||
|
||||
it("quotes source and destination paths for rsync", () => {
|
||||
const command = buildDirectRsyncCommand(
|
||||
endpoint,
|
||||
["/srv/a file", "/srv/it's-safe"],
|
||||
"/opt/releases",
|
||||
true,
|
||||
);
|
||||
expect(command).toContain("--partial --append-verify");
|
||||
expect(command).toContain("--protect-args");
|
||||
expect(command).toContain(quoteShell("/srv/a file"));
|
||||
expect(command).toContain(quoteShell("/srv/it's-safe"));
|
||||
expect(command).toContain("deploy@10.0.0.2:/opt/releases/");
|
||||
});
|
||||
|
||||
it("brackets IPv6 destinations", () => {
|
||||
const command = buildDirectProbeCommand({
|
||||
host: "2001:db8::2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
});
|
||||
expect(command).toContain("root@[2001:db8::2]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
hashSftpFile,
|
||||
verifySftpFileIntegrity,
|
||||
} from "../../../hosts/file-manager/transfer-integrity.js";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
function fakeSftp(contents: Record<string, Buffer | Buffer[]>): SFTPWrapper {
|
||||
return {
|
||||
createReadStream: vi.fn((path: string) => {
|
||||
const value = contents[path];
|
||||
if (value === undefined) {
|
||||
return new Readable({
|
||||
read() {
|
||||
this.destroy(new Error(`Missing file: ${path}`));
|
||||
},
|
||||
});
|
||||
}
|
||||
return Readable.from(Array.isArray(value) ? value : [value]);
|
||||
}),
|
||||
} as unknown as SFTPWrapper;
|
||||
}
|
||||
|
||||
describe("transfer integrity", () => {
|
||||
it("hashes all chunks in an SFTP file", async () => {
|
||||
const sftp = fakeSftp({
|
||||
"/file": [Buffer.from("hello "), Buffer.from("world")],
|
||||
});
|
||||
|
||||
await expect(hashSftpFile(sftp, "/file")).resolves.toBe(
|
||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts matching source and destination files", async () => {
|
||||
const source = fakeSftp({ "/source": Buffer.from("same bytes") });
|
||||
const dest = fakeSftp({ "/dest": Buffer.from("same bytes") });
|
||||
|
||||
await expect(
|
||||
verifySftpFileIntegrity(source, dest, "/source", "/dest"),
|
||||
).resolves.toMatchObject({ algorithm: "sha256" });
|
||||
});
|
||||
|
||||
it("rejects a corrupted destination", async () => {
|
||||
const source = fakeSftp({ "/source": Buffer.from("expected") });
|
||||
const dest = fakeSftp({ "/dest": Buffer.from("corrupted") });
|
||||
|
||||
await expect(
|
||||
verifySftpFileIntegrity(source, dest, "/source", "/dest"),
|
||||
).rejects.toThrow("SHA-256 verification failed for /source");
|
||||
});
|
||||
|
||||
it("aborts hashing with the caller's cancellation error", async () => {
|
||||
const sftp = fakeSftp({ "/file": Buffer.from("data") });
|
||||
const cancelled = new Error("cancelled by test");
|
||||
|
||||
await expect(
|
||||
hashSftpFile(
|
||||
sftp,
|
||||
"/file",
|
||||
() => true,
|
||||
() => cancelled,
|
||||
),
|
||||
).rejects.toBe(cancelled);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
estimateIncompressibleSample,
|
||||
resolveArchiveTransferMethod,
|
||||
type TransferScanSummary,
|
||||
} from "../../../hosts/file-manager/transfer-routing.js";
|
||||
|
||||
describe("transfer content sampling", () => {
|
||||
it("recognizes repetitive content as compressible", () => {
|
||||
expect(estimateIncompressibleSample(Buffer.alloc(64 * 1024, 65))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes high-entropy content as incompressible", () => {
|
||||
const sample = Buffer.alloc(64 * 1024);
|
||||
let state = 0x12345678;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
sample[i] = state & 0xff;
|
||||
}
|
||||
expect(estimateIncompressibleSample(sample)).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers sampled content over a misleading extension", () => {
|
||||
const summary: TransferScanSummary = {
|
||||
fileCount: 120,
|
||||
totalBytes: 1024 * 1024 * 1024,
|
||||
largestFileBytes: 32 * 1024 * 1024,
|
||||
incompressibleRatio: 0,
|
||||
sampledIncompressibleRatio: 1,
|
||||
};
|
||||
expect(
|
||||
resolveArchiveTransferMethod("auto", summary, "unix", "unix", true, true),
|
||||
).toBe("item_sftp");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
clearTransferProfiles,
|
||||
flushTransferProfiles,
|
||||
getRecentDirectRouteDecision,
|
||||
getTransferProfile,
|
||||
initializeTransferProfiles,
|
||||
recordDirectRouteBenchmark,
|
||||
recordDirectRouteOutcome,
|
||||
recordTransferProfile,
|
||||
selectTransferTuning,
|
||||
updateTransferProfile,
|
||||
} from "../../../hosts/file-manager/transfer-tuning.js";
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe("adaptive transfer tuning", () => {
|
||||
beforeEach(clearTransferProfiles);
|
||||
|
||||
it("keeps small files on one conservative lane", () => {
|
||||
expect(selectTransferTuning(8 * MB)).toEqual({
|
||||
parallelSegmentCount: 1,
|
||||
pipelineConcurrency: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses more lanes for large transfers without exceeding segment count", () => {
|
||||
expect(selectTransferTuning(2 * 1024 * MB)).toEqual({
|
||||
parallelSegmentCount: 4,
|
||||
pipelineConcurrency: 32,
|
||||
});
|
||||
expect(selectTransferTuning(300 * MB).parallelSegmentCount).toBe(2);
|
||||
});
|
||||
|
||||
it("honours an explicit lane selection", () => {
|
||||
expect(selectTransferTuning(2 * 1024 * MB, undefined, 3)).toMatchObject({
|
||||
parallelSegmentCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("backs off after failures", () => {
|
||||
const profile = updateTransferProfile(undefined, {
|
||||
bytes: 256 * MB,
|
||||
durationMs: 1000,
|
||||
lanes: 4,
|
||||
pipelineConcurrency: 32,
|
||||
failed: true,
|
||||
now: 1,
|
||||
});
|
||||
expect(profile.preferredLanes).toBe(2);
|
||||
expect(profile.pipelineConcurrency).toBe(16);
|
||||
});
|
||||
|
||||
it("learns and expires host-pair profiles", () => {
|
||||
recordTransferProfile("a->b", {
|
||||
bytes: 256 * MB,
|
||||
durationMs: 2000,
|
||||
lanes: 2,
|
||||
pipelineConcurrency: 32,
|
||||
failed: false,
|
||||
now: 100,
|
||||
});
|
||||
expect(getTransferProfile("a->b", 101)?.samples).toBe(1);
|
||||
expect(getTransferProfile("a->b", 8 * 24 * 60 * 60 * 1000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses recent route benchmarks and cools down failed direct paths", () => {
|
||||
recordDirectRouteBenchmark("a->b", 700, 1000, 100);
|
||||
expect(getRecentDirectRouteDecision("a->b", 1_000, 101)).toEqual({
|
||||
useDirect: true,
|
||||
directMs: 700,
|
||||
relayMs: 1000,
|
||||
});
|
||||
|
||||
recordDirectRouteOutcome("a->b", true, 102, 500);
|
||||
expect(getRecentDirectRouteDecision("a->b", 1_000, 103)?.useDirect).toBe(
|
||||
false,
|
||||
);
|
||||
expect(getRecentDirectRouteDecision("a->b", 1_000, 1_103)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists only anonymous local profiles across restarts", async () => {
|
||||
const previousDataDir = process.env.DATA_DIR;
|
||||
const dataDir = await fs.mkdtemp(path.join(tmpdir(), "termix-transfer-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
const rawKey = "root@10.0.0.1->deploy@10.0.0.2";
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
await initializeTransferProfiles();
|
||||
recordTransferProfile(rawKey, {
|
||||
bytes: 256 * MB,
|
||||
durationMs: 2000,
|
||||
lanes: 2,
|
||||
pipelineConcurrency: 16,
|
||||
failed: false,
|
||||
now,
|
||||
});
|
||||
recordDirectRouteBenchmark(rawKey, 700, 1000, now);
|
||||
await flushTransferProfiles();
|
||||
|
||||
const stored = await fs.readFile(
|
||||
path.join(dataDir, "adaptive-transfer-profiles.json"),
|
||||
"utf8",
|
||||
);
|
||||
expect(stored).not.toContain(rawKey);
|
||||
expect(Object.keys(JSON.parse(stored).profiles)[0]).toMatch(
|
||||
/^[a-f0-9]{64}$/,
|
||||
);
|
||||
|
||||
clearTransferProfiles();
|
||||
await initializeTransferProfiles();
|
||||
expect(getTransferProfile(rawKey, now + 1)).toMatchObject({ samples: 1 });
|
||||
expect(
|
||||
getRecentDirectRouteDecision(rawKey, 1_000, now + 1),
|
||||
).toMatchObject({ useDirect: true });
|
||||
} finally {
|
||||
clearTransferProfiles();
|
||||
if (previousDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = previousDataDir;
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SFTPWrapper } from "ssh2";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
isSafeTrashSource,
|
||||
listTrash,
|
||||
moveToTrash,
|
||||
permanentlyDeleteTrashItem,
|
||||
restoreTrashItem,
|
||||
} from "../../../hosts/file-manager/trash-service.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
// Windows only allows symlink creation with elevation or Developer Mode, so the
|
||||
// symlink safety test is skipped where the OS refuses to create one at all.
|
||||
const canCreateSymlinks = (() => {
|
||||
const probe = fs.mkdtempSync(path.join(os.tmpdir(), "termix-symlink-probe-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(probe, "target"));
|
||||
fs.symlinkSync(path.join(probe, "target"), path.join(probe, "link"), "dir");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
fs.rmSync(probe, { recursive: true, force: true });
|
||||
}
|
||||
})();
|
||||
|
||||
function localSftp(home: string): SFTPWrapper {
|
||||
const callback = <T>(
|
||||
promise: Promise<T>,
|
||||
done: (error: Error | undefined, value?: T) => void,
|
||||
) =>
|
||||
promise
|
||||
.then((value) => done(undefined, value))
|
||||
.catch((error) => done(error));
|
||||
return {
|
||||
realpath(
|
||||
_target: string,
|
||||
done: (error: Error | undefined, value?: string) => void,
|
||||
) {
|
||||
done(undefined, home);
|
||||
},
|
||||
stat(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: fs.Stats) => void,
|
||||
) {
|
||||
callback(fs.promises.stat(target), done);
|
||||
},
|
||||
lstat(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: fs.Stats) => void,
|
||||
) {
|
||||
callback(fs.promises.lstat(target), done);
|
||||
},
|
||||
readdir(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: unknown[]) => void,
|
||||
) {
|
||||
callback(
|
||||
fs.promises.readdir(target, { withFileTypes: true }).then((entries) =>
|
||||
entries.map((entry) => ({
|
||||
filename: entry.name,
|
||||
longname: entry.name,
|
||||
attrs: {},
|
||||
})),
|
||||
),
|
||||
done,
|
||||
);
|
||||
},
|
||||
readFile(
|
||||
target: string,
|
||||
done: (error: Error | undefined, value?: Buffer) => void,
|
||||
) {
|
||||
callback(fs.promises.readFile(target), done);
|
||||
},
|
||||
writeFile(target: string, data: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.writeFile(target, data)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
rename(from: string, to: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.rename(from, to)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
unlink(target: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.unlink(target)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
mkdir(target: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.mkdir(target)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
rmdir(target: string, done: (error?: Error) => void) {
|
||||
fs.promises
|
||||
.rmdir(target)
|
||||
.then(() => done())
|
||||
.catch(done);
|
||||
},
|
||||
} as unknown as SFTPWrapper;
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const home = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "termix-trash-"),
|
||||
);
|
||||
temporaryDirectories.push(home);
|
||||
return { home, sftp: localSftp(home) };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) =>
|
||||
fs.promises.rm(directory, { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe("file manager trash safety", () => {
|
||||
it("rejects roots and paths inside the trash", () => {
|
||||
expect(isSafeTrashSource("/", "/home/user/.termix-trash")).toBe(false);
|
||||
expect(isSafeTrashSource("C:/", "C:/Users/user/.termix-trash")).toBe(false);
|
||||
expect(
|
||||
isSafeTrashSource(
|
||||
"/home/user/.termix-trash/files/a",
|
||||
"/home/user/.termix-trash",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts ordinary files and directories", () => {
|
||||
expect(
|
||||
isSafeTrashSource("/home/user/report.txt", "/home/user/.termix-trash"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("moves, lists, and restores a file without changing its contents", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "report.txt");
|
||||
await fs.promises.writeFile(original, "important");
|
||||
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
expect(fs.existsSync(original)).toBe(false);
|
||||
expect(await listTrash(sftp, 7)).toEqual([trashed]);
|
||||
|
||||
await restoreTrashItem(sftp, trashed.id);
|
||||
expect(await fs.promises.readFile(original, "utf8")).toBe("important");
|
||||
expect(await listTrash(sftp, 7)).toEqual([]);
|
||||
});
|
||||
|
||||
it("permanently deletes only the stored trash path", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "folder");
|
||||
await fs.promises.mkdir(original);
|
||||
await fs.promises.writeFile(path.join(original, "nested.txt"), "data");
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
|
||||
await permanentlyDeleteTrashItem(sftp, trashed.id);
|
||||
expect(await listTrash(sftp, 7)).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(!canCreateSymlinks)(
|
||||
"does not follow directory symlinks during permanent deletion",
|
||||
async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const target = path.join(home, "target");
|
||||
const link = path.join(home, "link");
|
||||
await fs.promises.mkdir(target);
|
||||
await fs.promises.writeFile(path.join(target, "keep.txt"), "keep");
|
||||
await fs.promises.symlink(target, link, "dir");
|
||||
|
||||
const trashed = await moveToTrash(sftp, link);
|
||||
await permanentlyDeleteTrashItem(sftp, trashed.id);
|
||||
|
||||
expect(
|
||||
await fs.promises.readFile(path.join(target, "keep.txt"), "utf8"),
|
||||
).toBe("keep");
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses tampered metadata instead of deleting an arbitrary path", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "discard.txt");
|
||||
const protectedFile = path.join(home, "keep.txt");
|
||||
await fs.promises.writeFile(original, "discard");
|
||||
await fs.promises.writeFile(protectedFile, "keep");
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
const metadata = path.join(
|
||||
home,
|
||||
".termix-trash",
|
||||
"info",
|
||||
`${trashed.id}.json`,
|
||||
);
|
||||
const data = JSON.parse(await fs.promises.readFile(metadata, "utf8"));
|
||||
data.trashPath = protectedFile;
|
||||
await fs.promises.writeFile(metadata, JSON.stringify(data));
|
||||
|
||||
await expect(permanentlyDeleteTrashItem(sftp, trashed.id)).rejects.toThrow(
|
||||
"Invalid trash metadata",
|
||||
);
|
||||
expect(await fs.promises.readFile(protectedFile, "utf8")).toBe("keep");
|
||||
});
|
||||
|
||||
it("prunes items after the configured retention period", async () => {
|
||||
const { home, sftp } = await fixture();
|
||||
const original = path.join(home, "old.txt");
|
||||
await fs.promises.writeFile(original, "old");
|
||||
const trashed = await moveToTrash(sftp, original);
|
||||
const metadata = path.join(
|
||||
home,
|
||||
".termix-trash",
|
||||
"info",
|
||||
`${trashed.id}.json`,
|
||||
);
|
||||
const data = JSON.parse(await fs.promises.readFile(metadata, "utf8"));
|
||||
data.deletedAt = "2020-01-01T00:00:00.000Z";
|
||||
await fs.promises.writeFile(metadata, JSON.stringify(data));
|
||||
|
||||
expect(await listTrash(sftp, 7)).toEqual([]);
|
||||
expect(
|
||||
fs.existsSync(path.join(home, ".termix-trash", "files", trashed.id)),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRdpSettings,
|
||||
resolveRdpDomain,
|
||||
} from "../../../hosts/guacamole/rdp-settings.js";
|
||||
|
||||
describe("buildRdpSettings", () => {
|
||||
it("keeps saved RDP settings authoritative over stale advanced config", () => {
|
||||
expect(
|
||||
buildRdpSettings({
|
||||
port: 3390,
|
||||
domain: "EXAMPLE",
|
||||
security: "nla",
|
||||
ignoreCert: true,
|
||||
guacConfig: {
|
||||
port: 3389,
|
||||
domain: "OLD",
|
||||
security: "rdp",
|
||||
"ignore-cert": false,
|
||||
"color-depth": 24,
|
||||
},
|
||||
guacdOverrides: { guacdHost: "guacd.internal" },
|
||||
}),
|
||||
).toEqual({
|
||||
port: 3390,
|
||||
domain: "EXAMPLE",
|
||||
security: "nla",
|
||||
"ignore-cert": true,
|
||||
"color-depth": 24,
|
||||
guacdHost: "guacd.internal",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an advanced security value when no saved value exists", () => {
|
||||
expect(
|
||||
buildRdpSettings({
|
||||
port: 3389,
|
||||
ignoreCert: false,
|
||||
guacConfig: { security: "tls" },
|
||||
guacdOverrides: {},
|
||||
}).security,
|
||||
).toBe("tls");
|
||||
});
|
||||
|
||||
it("uses the prompted domain for prompt-on-connect authentication", () => {
|
||||
expect(resolveRdpDomain("none", "EXAMPLE", "OLD")).toBe("EXAMPLE");
|
||||
expect(resolveRdpDomain("none", "", "OLD")).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the stored domain for saved authentication", () => {
|
||||
expect(resolveRdpDomain("direct", "EXAMPLE", "SAVED")).toBe("SAVED");
|
||||
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findHostIdBySyncId = vi.fn();
|
||||
const canAccessHost = vi.fn();
|
||||
const findHostOwnerId = vi.fn();
|
||||
const findHostById = vi.fn();
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostIdBySyncId,
|
||||
findHostOwnerId,
|
||||
findHostById,
|
||||
}),
|
||||
createCurrentVaultProfileRepository: () => ({}),
|
||||
createCurrentUserRepository: () => ({ findById: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: { getInstance: () => ({ canAccessHost }) },
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: vi.fn() }));
|
||||
vi.mock("../../utils/shared-host-auth-resolver.js", () => ({
|
||||
resolveRecipientSharedHostAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* A numeric host id belongs to whichever database produced it. Resolving the
|
||||
* desktop app's id against a sync server's table lands on whatever host owns
|
||||
* that number there — a different machine, with its own address, credentials
|
||||
* and host key. `sync_id` is the same string on both sides.
|
||||
*/
|
||||
describe("resolveHostBySyncId", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
canAccessHost.mockResolvedValue({ hasAccess: true });
|
||||
findHostOwnerId.mockResolvedValue("user-1");
|
||||
});
|
||||
|
||||
it("resolves the row carrying that sync id, whatever its local id is", async () => {
|
||||
// The client's row is id 3 locally; here the same host is id 41.
|
||||
findHostIdBySyncId.mockResolvedValue(41);
|
||||
findHostById.mockResolvedValue({
|
||||
id: 41,
|
||||
ip: "10.0.0.7",
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
const { resolveHostBySyncId } =
|
||||
await import("../../hosts/host-resolver.js");
|
||||
const host = await resolveHostBySyncId("sync-abc", "user-1");
|
||||
|
||||
expect(findHostIdBySyncId).toHaveBeenCalledWith("sync-abc");
|
||||
expect(findHostById).toHaveBeenCalledWith(41, "user-1");
|
||||
expect(host?.ip).toBe("10.0.0.7");
|
||||
});
|
||||
|
||||
it("returns null for a sync id this server does not have", async () => {
|
||||
// Falling back to the numeric id here is what picked the wrong machine.
|
||||
findHostIdBySyncId.mockResolvedValue(null);
|
||||
|
||||
const { resolveHostBySyncId } =
|
||||
await import("../../hosts/host-resolver.js");
|
||||
|
||||
await expect(resolveHostBySyncId("sync-unknown", "user-1")).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
expect(findHostById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still refuses a host the caller may not reach", async () => {
|
||||
// The lookup is unscoped so shared hosts resolve; permission is decided by
|
||||
// the id-based path, which must not be bypassed.
|
||||
findHostIdBySyncId.mockResolvedValue(41);
|
||||
canAccessHost.mockResolvedValue({ hasAccess: false });
|
||||
|
||||
const { resolveHostBySyncId } =
|
||||
await import("../../hosts/host-resolver.js");
|
||||
|
||||
await expect(resolveHostBySyncId("sync-abc", "intruder")).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
expect(findHostById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Once the alert rules have been migrated into automations, both systems hold
|
||||
* a copy of every rule. If the old engine kept evaluating, every alert would
|
||||
* be delivered twice.
|
||||
*/
|
||||
|
||||
const listEnabledRulesForHost = vi.fn();
|
||||
const listEnabledRulesForHostUser = vi.fn();
|
||||
const createFiring = vi.fn();
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentAlertRepository: () => ({
|
||||
listEnabledRulesForHost,
|
||||
listEnabledRulesForHostUser,
|
||||
createFiring,
|
||||
pruneFiringsOlderThan: vi.fn(),
|
||||
listEnabledChannelsForRule: vi.fn(async () => []),
|
||||
findRuleById: vi.fn(async () => null),
|
||||
getHostDisplayName: vi.fn(async () => "host"),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
statsLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/notification-sender.js", () => ({
|
||||
sendNotification: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/discord-sender.js", () => ({
|
||||
sendDiscord: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const alertEngineModule =
|
||||
await import("../../../hosts/metrics/alert-engine.js");
|
||||
|
||||
const cpuRule = {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 50,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listEnabledRulesForHost.mockResolvedValue([cpuRule]);
|
||||
listEnabledRulesForHostUser.mockResolvedValue([cpuRule]);
|
||||
});
|
||||
|
||||
describe("AlertEngine before migration", () => {
|
||||
it("still evaluates rules", async () => {
|
||||
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(false);
|
||||
|
||||
await alertEngineModule.AlertEngine.getInstance().evaluateMetrics(7, {
|
||||
cpu: { percent: 90 },
|
||||
});
|
||||
|
||||
expect(listEnabledRulesForHost).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertEngine after migration", () => {
|
||||
it("stops evaluating every trigger type", async () => {
|
||||
alertEngineModule.markAlertEngineSuperseded();
|
||||
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(true);
|
||||
|
||||
const engine = alertEngineModule.AlertEngine.getInstance();
|
||||
await engine.evaluateMetrics(7, { cpu: { percent: 99 } });
|
||||
await engine.evaluateStatus(7, false);
|
||||
await engine.evaluateHealthCheck(7, "user-1", "web", false);
|
||||
await engine.evaluateUserLogin(7, "user-1", "root", "10.0.0.1");
|
||||
|
||||
// Nothing is even loaded, so nothing can be delivered a second time.
|
||||
expect(listEnabledRulesForHost).not.toHaveBeenCalled();
|
||||
expect(listEnabledRulesForHostUser).not.toHaveBeenCalled();
|
||||
expect(createFiring).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
supportsMetrics,
|
||||
isTcpPingEnabled,
|
||||
parseStatusHostIds,
|
||||
tcpPingThroughJumpHost,
|
||||
} from "../../../hosts/metrics/helpers.js";
|
||||
import { createConnectionLog } from "../../../hosts/connection-log.js";
|
||||
@@ -53,6 +54,17 @@ describe("isTcpPingEnabled", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStatusHostIds", () => {
|
||||
it("distinguishes an unrestricted request from an empty host set", () => {
|
||||
expect(parseStatusHostIds(undefined)).toBeNull();
|
||||
expect(parseStatusHostIds("")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("keeps only valid positive host IDs", () => {
|
||||
expect(parseStatusHostIds("7,2,7,-1,nope,1.5")).toEqual(new Set([7, 2]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConnectionLog", () => {
|
||||
it("builds a log entry without id/timestamp", () => {
|
||||
const entry = createConnectionLog("info", "connection", "Connecting", {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const historyCreate = vi.fn();
|
||||
const historyPrune = vi.fn();
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
getCurrentSettingValue: () => null,
|
||||
createCurrentProxmoxNodeHistoryRepository: () => ({
|
||||
create: historyCreate,
|
||||
pruneOlderThan: historyPrune,
|
||||
}),
|
||||
}));
|
||||
|
||||
const collectProxmoxStats = vi.fn();
|
||||
vi.mock("../../../hosts/metrics/proxmox/collect-proxmox-stats.js", () => ({
|
||||
collectProxmoxStats: (...args: unknown[]) => collectProxmoxStats(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
ProxmoxPollingManager,
|
||||
parseProxmoxStatsConfig,
|
||||
} from "../../../hosts/metrics/proxmox-stats-polling.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
interface TestHost {
|
||||
id: number;
|
||||
userId: string;
|
||||
proxmoxStatsConfig?: string | null;
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
node: {
|
||||
cpu: { percent: 10, cores: 4, load: [0.1, 0.2, 0.3] },
|
||||
memory: { percent: 20, usedGiB: 2, totalGiB: 8 },
|
||||
disk: { percent: 30, usedGiB: 30, totalGiB: 100 },
|
||||
uptime: { seconds: 100, formatted: "0d 0h 1m" },
|
||||
system: { hostname: "pve1", kernel: "6.8", pveVersion: "8.2" },
|
||||
},
|
||||
network: { interfaces: [{ name: "eth0", rxBytes: "10", txBytes: "20" }] },
|
||||
guests: { guests: [], counts: { running: 0, stopped: 0, total: 0 } },
|
||||
storage: { pools: [] },
|
||||
cluster: { clustered: false },
|
||||
lastChecked: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
historyCreate.mockReset();
|
||||
historyPrune.mockReset();
|
||||
collectProxmoxStats.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("parseProxmoxStatsConfig", () => {
|
||||
it("returns default poll interval when config is missing", () => {
|
||||
expect(parseProxmoxStatsConfig(null)).toEqual({
|
||||
nodeName: null,
|
||||
pollInterval: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a JSON string config", () => {
|
||||
expect(
|
||||
parseProxmoxStatsConfig('{"nodeName":"pve1","pollInterval":30}'),
|
||||
).toEqual({ nodeName: "pve1", pollInterval: 30 });
|
||||
});
|
||||
|
||||
it("falls back to defaults on malformed JSON", () => {
|
||||
expect(parseProxmoxStatsConfig("{not json")).toEqual({
|
||||
nodeName: null,
|
||||
pollInterval: 60,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProxmoxPollingManager", () => {
|
||||
function makeManager(host: TestHost) {
|
||||
const fetchHostById = vi.fn(async () => host);
|
||||
const withSshConnection = vi.fn(
|
||||
async (_host: TestHost, fn: (client: Client) => Promise<unknown>) =>
|
||||
fn({} as Client),
|
||||
);
|
||||
const manager = new ProxmoxPollingManager<TestHost>({
|
||||
fetchHostById,
|
||||
withSshConnection,
|
||||
});
|
||||
return { manager, fetchHostById, withSshConnection };
|
||||
}
|
||||
|
||||
it("starts polling and caches a snapshot when the first viewer registers", async () => {
|
||||
const host: TestHost = { id: 1, userId: "user-1" };
|
||||
collectProxmoxStats.mockResolvedValue(snapshot());
|
||||
const { manager, withSshConnection } = makeManager(host);
|
||||
|
||||
manager.registerViewer(1, "viewer-1", "user-1");
|
||||
// registerViewer kicks off polling via a fire-and-forget promise chain.
|
||||
await vi.waitFor(() => {
|
||||
expect(withSshConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const cached = manager.getStats(1);
|
||||
expect(cached?.data.node.cpu.percent).toBe(10);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it("stops polling once the last viewer unregisters", async () => {
|
||||
const host: TestHost = { id: 2, userId: "user-1" };
|
||||
collectProxmoxStats.mockResolvedValue(snapshot());
|
||||
const { manager } = makeManager(host);
|
||||
|
||||
manager.registerViewer(2, "viewer-a", "user-1");
|
||||
manager.registerViewer(2, "viewer-b", "user-1");
|
||||
await vi.waitFor(() => expect(manager.getStats(2)).toBeDefined());
|
||||
|
||||
manager.unregisterViewer(2, "viewer-a");
|
||||
// one viewer left - stats stay cached
|
||||
expect(manager.getStats(2)).toBeDefined();
|
||||
|
||||
manager.unregisterViewer(2, "viewer-b");
|
||||
// Cached snapshot is retained (not cleared) but the interval is stopped;
|
||||
// registering a new viewer must restart polling.
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it("updateHeartbeat returns false for an unknown session", () => {
|
||||
const { manager } = makeManager({ id: 3, userId: "user-1" });
|
||||
expect(manager.updateHeartbeat("nope")).toBe(false);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
it("records an error snapshot when collection fails, without throwing", async () => {
|
||||
const host: TestHost = { id: 4, userId: "user-1" };
|
||||
collectProxmoxStats.mockRejectedValue(new Error("pvesh not found"));
|
||||
const { manager } = makeManager(host);
|
||||
|
||||
manager.registerViewer(4, "viewer-1", "user-1");
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getError(4)?.error).toBe("pvesh not found");
|
||||
});
|
||||
expect(manager.getStats(4)).toBeUndefined();
|
||||
manager.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
|
||||
import { collectProxmoxClusterHealth } from "../../../../hosts/metrics/proxmox/cluster-health-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxClusterHealth", () => {
|
||||
it("reports clustered:false for a standalone node (no cluster entry)", async () => {
|
||||
execCommand.mockResolvedValueOnce(result(JSON.stringify([]), 0));
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res).toEqual({ clustered: false });
|
||||
});
|
||||
|
||||
it("parses a clustered response with quorum and node entries", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{ type: "cluster", name: "prod-cluster", quorate: 1, nodes: 3 },
|
||||
{ type: "node", name: "pve1", online: 1, local: 1, ip: "10.0.0.1" },
|
||||
{ type: "node", name: "pve2", online: 0, local: 0, ip: "10.0.0.2" },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res.clustered).toBe(true);
|
||||
if (res.clustered) {
|
||||
expect(res.quorate).toBe(true);
|
||||
expect(res.clusterName).toBe("prod-cluster");
|
||||
expect(res.nodes).toHaveLength(2);
|
||||
expect(res.nodes[0]).toEqual({
|
||||
name: "pve1",
|
||||
online: true,
|
||||
local: true,
|
||||
ip: "10.0.0.1",
|
||||
});
|
||||
expect(res.nodes[1].online).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns clustered:false when pvesh fails", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res).toEqual({ clustered: false });
|
||||
});
|
||||
|
||||
it("returns clustered:false on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("not json", 0));
|
||||
const res = await collectProxmoxClusterHealth(fakeClient);
|
||||
expect(res).toEqual({ clustered: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxStats } from "../../../../hosts/metrics/proxmox/collect-proxmox-stats.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxStats", () => {
|
||||
it("throws a distinguishable error when pvesh is missing", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("missing"));
|
||||
|
||||
await expect(collectProxmoxStats(fakeClient, null)).rejects.toThrow(
|
||||
/pvesh not found/i,
|
||||
);
|
||||
// Only the pvesh-presence check should have run - no node resolution or
|
||||
// per-collector execs once that check fails.
|
||||
expect(execCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("auto-detects the node name via hostname when none is configured", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
|
||||
execCommand.mockResolvedValueOnce(result("pve-auto\n")); // hostname
|
||||
// Five collectors run concurrently after that; give each a benign failing
|
||||
// response so the aggregator still resolves with null-filled sub-shapes.
|
||||
execCommand.mockResolvedValue(result("", 1));
|
||||
|
||||
const snapshot = await collectProxmoxStats(fakeClient, null);
|
||||
expect(snapshot.lastChecked).toBeTruthy();
|
||||
expect(snapshot.node).toBeDefined();
|
||||
expect(snapshot.guests).toBeDefined();
|
||||
expect(snapshot.storage).toBeDefined();
|
||||
expect(snapshot.cluster).toEqual({ clustered: false });
|
||||
});
|
||||
|
||||
it("uses the configured node name when it is safe, skipping hostname detection", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
|
||||
execCommand.mockResolvedValue(result("", 1)); // all collector calls fail benignly
|
||||
|
||||
await collectProxmoxStats(fakeClient, "my-node");
|
||||
|
||||
// hostname auto-detection command should never have been issued.
|
||||
const calls = execCommand.mock.calls.map((c) => c[1] as string);
|
||||
expect(calls).not.toContain("hostname");
|
||||
});
|
||||
|
||||
it("rejects an unsafe configured node name", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
|
||||
|
||||
await expect(collectProxmoxStats(fakeClient, "bad;node")).rejects.toThrow(
|
||||
/valid Proxmox node name/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxGuestsSummary } from "../../../../hosts/metrics/proxmox/guests-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxGuestsSummary", () => {
|
||||
it("filters to the target node, excludes templates, and computes percentages", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{
|
||||
type: "qemu",
|
||||
node: "pve1",
|
||||
vmid: 100,
|
||||
name: "vm-a",
|
||||
status: "running",
|
||||
cpu: 0.25,
|
||||
mem: 2 * 1024 ** 3,
|
||||
maxmem: 4 * 1024 ** 3,
|
||||
disk: 10 * 1024 ** 3,
|
||||
maxdisk: 40 * 1024 ** 3,
|
||||
uptime: 3600,
|
||||
},
|
||||
{
|
||||
type: "lxc",
|
||||
node: "pve1",
|
||||
vmid: 101,
|
||||
name: "ct-b",
|
||||
status: "stopped",
|
||||
cpu: 0,
|
||||
mem: 0,
|
||||
maxmem: 512 * 1024 ** 2,
|
||||
disk: 0,
|
||||
maxdisk: 8 * 1024 ** 3,
|
||||
uptime: 0,
|
||||
},
|
||||
{
|
||||
// different node - excluded
|
||||
type: "qemu",
|
||||
node: "pve2",
|
||||
vmid: 200,
|
||||
name: "elsewhere",
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
// template - excluded
|
||||
type: "qemu",
|
||||
node: "pve1",
|
||||
vmid: 999,
|
||||
name: "template",
|
||||
status: "stopped",
|
||||
template: 1,
|
||||
},
|
||||
{
|
||||
// non-guest resource type - excluded
|
||||
type: "storage",
|
||||
node: "pve1",
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
|
||||
expect(res.guests).toHaveLength(2);
|
||||
expect(res.counts).toEqual({ running: 1, stopped: 1, total: 2 });
|
||||
|
||||
const vmA = res.guests.find((g) => g.vmid === 100)!;
|
||||
expect(vmA.cpuPercent).toBe(25);
|
||||
expect(vmA.memPercent).toBe(50);
|
||||
expect(vmA.diskPercent).toBe(25);
|
||||
});
|
||||
|
||||
it("reports null disk fields (not a false 0%) when maxdisk is 0", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{
|
||||
type: "qemu",
|
||||
node: "pve1",
|
||||
vmid: 100,
|
||||
name: "vm-no-agent",
|
||||
status: "running",
|
||||
cpu: 0.1,
|
||||
mem: 1024,
|
||||
maxmem: 2048,
|
||||
disk: 0,
|
||||
maxdisk: 0,
|
||||
uptime: 10,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests[0].diskPercent).toBeNull();
|
||||
expect(res.guests[0].diskUsedGiB).toBeNull();
|
||||
expect(res.guests[0].diskTotalGiB).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an empty guest list when there are no matching resources", async () => {
|
||||
execCommand.mockResolvedValueOnce(result(JSON.stringify([]), 0));
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests).toEqual([]);
|
||||
expect(res.counts).toEqual({ running: 0, stopped: 0, total: 0 });
|
||||
});
|
||||
|
||||
it("returns an empty result when pvesh is missing (non-zero exit)", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 127));
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty result on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("{not json", 0));
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
|
||||
expect(res.guests).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxGuestsSummary(fakeClient, "../etc");
|
||||
expect(res.guests).toEqual([]);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
|
||||
import { collectProxmoxNodeNetwork } from "../../../../hosts/metrics/proxmox/node-network-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxNodeNetwork", () => {
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxNodeNetwork(fakeClient, "bad;name");
|
||||
expect(res.interfaces).toEqual([]);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to /proc/net/dev when pvesh netstat fails", async () => {
|
||||
// First call: pvesh netstat -> failure.
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
// Fallback calls: ip addr, ip link, /proc/net/dev.
|
||||
execCommand.mockResolvedValueOnce(result("eth0 10.0.0.5/24\n"));
|
||||
execCommand.mockResolvedValueOnce(result("eth0 UP\n"));
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
"Inter-| Receive\n" +
|
||||
" face |bytes packets\n" +
|
||||
"eth0: 123456 10 0 0 0 0 0 0 654321 20 0 0 0 0 0 0\n",
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxNodeNetwork(fakeClient, "pve1");
|
||||
expect(res.interfaces).toHaveLength(1);
|
||||
expect(res.interfaces[0]).toMatchObject({
|
||||
name: "eth0",
|
||||
ip: "10.0.0.5",
|
||||
state: "UP",
|
||||
rxBytes: "123456",
|
||||
txBytes: "654321",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to /proc/net/dev when pvesh returns unparseable data", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("not json", 0));
|
||||
execCommand.mockResolvedValueOnce(result(""));
|
||||
execCommand.mockResolvedValueOnce(result(""));
|
||||
execCommand.mockResolvedValueOnce(result("Inter-| Receive\n face |\n"));
|
||||
|
||||
const res = await collectProxmoxNodeNetwork(fakeClient, "pve1");
|
||||
expect(res.interfaces).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxNodeStatus } from "../../../../hosts/metrics/proxmox/node-status-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxNodeStatus", () => {
|
||||
it("parses a healthy pvesh /nodes/{node}/status response", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify({
|
||||
cpu: 0.15,
|
||||
cpuinfo: { cores: 8 },
|
||||
loadavg: ["0.5", "0.6", "0.7"],
|
||||
memory: { used: 4 * 1024 ** 3, total: 16 * 1024 ** 3 },
|
||||
rootfs: { used: 20 * 1024 ** 3, total: 100 * 1024 ** 3 },
|
||||
uptime: 90061,
|
||||
hostname: "pve1",
|
||||
kversion: "Linux 6.8.0",
|
||||
pveversion: "pve-manager/8.2.0",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
|
||||
|
||||
expect(res.cpu.percent).toBe(15);
|
||||
expect(res.cpu.cores).toBe(8);
|
||||
expect(res.cpu.load).toEqual([0.5, 0.6, 0.7]);
|
||||
expect(res.memory.percent).toBe(25);
|
||||
expect(res.memory.usedGiB).toBeCloseTo(4, 1);
|
||||
expect(res.memory.totalGiB).toBeCloseTo(16, 1);
|
||||
expect(res.disk.percent).toBe(20);
|
||||
expect(res.uptime.seconds).toBe(90061);
|
||||
expect(res.uptime.formatted).toBe("1d 1h 1m");
|
||||
expect(res.system.hostname).toBe("pve1");
|
||||
expect(res.system.kernel).toBe("Linux 6.8.0");
|
||||
expect(res.system.pveVersion).toBe("pve-manager/8.2.0");
|
||||
});
|
||||
|
||||
it("returns a fully null-filled shape when pvesh exits non-zero", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
|
||||
expect(res.cpu.percent).toBeNull();
|
||||
expect(res.memory.percent).toBeNull();
|
||||
expect(res.disk.percent).toBeNull();
|
||||
expect(res.uptime.seconds).toBeNull();
|
||||
expect(res.system.hostname).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null-filled shape on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("not json", 0));
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
|
||||
expect(res.cpu.percent).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxNodeStatus(fakeClient, "pve1; rm -rf /");
|
||||
expect(res.cpu.percent).toBeNull();
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../../../hosts/metrics/widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
toFixedNum: (n: number | null | undefined, digits = 2) => {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
},
|
||||
}));
|
||||
|
||||
import { collectProxmoxStorage } from "../../../../hosts/metrics/proxmox/storage-collector.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, code: number | null = 0) {
|
||||
return { stdout, stderr: "", code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("collectProxmoxStorage", () => {
|
||||
it("parses storage pools with usage percentages", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
JSON.stringify([
|
||||
{
|
||||
storage: "local",
|
||||
type: "dir",
|
||||
active: 1,
|
||||
enabled: 1,
|
||||
used: 20 * 1024 ** 3,
|
||||
total: 100 * 1024 ** 3,
|
||||
avail: 80 * 1024 ** 3,
|
||||
},
|
||||
{
|
||||
storage: "local-zfs",
|
||||
type: "zfspool",
|
||||
active: 0,
|
||||
enabled: 1,
|
||||
used: 0,
|
||||
total: 0,
|
||||
avail: 0,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const res = await collectProxmoxStorage(fakeClient, "pve1");
|
||||
expect(res.pools).toHaveLength(2);
|
||||
expect(res.pools[0].name).toBe("local");
|
||||
expect(res.pools[0].active).toBe(true);
|
||||
expect(res.pools[0].percent).toBe(20);
|
||||
expect(res.pools[1].active).toBe(false);
|
||||
expect(res.pools[1].percent).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an empty pool list when pvesh fails", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", 1));
|
||||
const res = await collectProxmoxStorage(fakeClient, "pve1");
|
||||
expect(res.pools).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty pool list on malformed JSON", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("nope", 0));
|
||||
const res = await collectProxmoxStorage(fakeClient, "pve1");
|
||||
expect(res.pools).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an unsafe node name before running any command", async () => {
|
||||
const res = await collectProxmoxStorage(fakeClient, "$(whoami)");
|
||||
expect(res.pools).toEqual([]);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,22 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
canStartInitialMetrics,
|
||||
ConcurrentLimiter,
|
||||
HostPollCache,
|
||||
metricsConcurrencyFor,
|
||||
} from "../../../hosts/metrics/state.js";
|
||||
|
||||
describe("initial metrics admission", () => {
|
||||
it("requires both an active viewer and a confirmed online status", () => {
|
||||
expect(canStartInitialMetrics("online", true)).toBe(true);
|
||||
expect(canStartInitialMetrics("reachable", true)).toBe(true);
|
||||
expect(canStartInitialMetrics("offline", true)).toBe(false);
|
||||
expect(canStartInitialMetrics(undefined, true)).toBe(false);
|
||||
expect(canStartInitialMetrics("online", false)).toBe(false);
|
||||
expect(canStartInitialMetrics(undefined, true, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConcurrentLimiter", () => {
|
||||
it("never exceeds max concurrent runners", async () => {
|
||||
const limiter = new ConcurrentLimiter(2);
|
||||
@@ -51,6 +64,161 @@ describe("ConcurrentLimiter", () => {
|
||||
it("rejects invalid maxConcurrent", () => {
|
||||
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
|
||||
});
|
||||
|
||||
describe("setLimit", () => {
|
||||
it("releases queued waiters as soon as the ceiling is raised", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(1);
|
||||
expect(limiter.pendingCount).toBe(3);
|
||||
|
||||
// Widening must drain the backlog without waiting for the running job.
|
||||
limiter.setLimit(4);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(4);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
|
||||
release.forEach((fn) => fn());
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not over-release beyond the new ceiling", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
// Later waves of woken jobs enqueue their own resolvers, so draining has
|
||||
// to keep going until nothing is left rather than flushing a snapshot.
|
||||
const drain = async () => {
|
||||
while (release.length > 0) {
|
||||
release.splice(0).forEach((fn) => fn());
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
limiter.setLimit(3);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(3);
|
||||
expect(limiter.pendingCount).toBe(2);
|
||||
|
||||
await drain();
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("lets running work finish when the ceiling shrinks", async () => {
|
||||
const limiter = new ConcurrentLimiter(4);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
const drain = async () => {
|
||||
while (release.length > 0) {
|
||||
release.splice(0).forEach((fn) => fn());
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(4);
|
||||
|
||||
// Shrinking never kills in-flight work; it applies to later releases.
|
||||
limiter.setLimit(2);
|
||||
expect(limiter.activeCount).toBe(4);
|
||||
|
||||
await drain();
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
// The two queued jobs ran only after the shrink, so they never pushed
|
||||
// occupancy back up to the old width.
|
||||
expect(peak).toBe(4);
|
||||
});
|
||||
|
||||
it("rejects an invalid new limit", () => {
|
||||
const limiter = new ConcurrentLimiter(2);
|
||||
expect(() => limiter.setLimit(0)).toThrow(/maxConcurrent/);
|
||||
expect(limiter.limit).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("metricsConcurrencyFor", () => {
|
||||
it("keeps a floor for small installs", () => {
|
||||
expect(metricsConcurrencyFor(0, {})).toBe(5);
|
||||
expect(metricsConcurrencyFor(1, {})).toBe(5);
|
||||
expect(metricsConcurrencyFor(60, {})).toBe(5);
|
||||
});
|
||||
|
||||
it("scales up with the fleet", () => {
|
||||
expect(metricsConcurrencyFor(200, {})).toBe(10);
|
||||
expect(metricsConcurrencyFor(500, {})).toBe(25);
|
||||
});
|
||||
|
||||
it("caps so a huge fleet cannot exhaust the host", () => {
|
||||
expect(metricsConcurrencyFor(100000, {})).toBe(50);
|
||||
});
|
||||
|
||||
it("lets an operator override the sizing", () => {
|
||||
expect(metricsConcurrencyFor(1000, { METRICS_POLL_CONCURRENCY: "8" })).toBe(
|
||||
8,
|
||||
);
|
||||
});
|
||||
|
||||
it("still caps an oversized override", () => {
|
||||
expect(
|
||||
metricsConcurrencyFor(10, { METRICS_POLL_CONCURRENCY: "9999" }),
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
it("ignores a nonsense override", () => {
|
||||
expect(
|
||||
metricsConcurrencyFor(500, { METRICS_POLL_CONCURRENCY: "abc" }),
|
||||
).toBe(25);
|
||||
});
|
||||
|
||||
it("sweeps 500 hosts inside a 30s interval, which the old fixed 5 could not", () => {
|
||||
const POLL_MS = 400;
|
||||
const hosts = 500;
|
||||
const sweepAt = (c: number) => Math.ceil(hosts / c) * POLL_MS;
|
||||
|
||||
expect(sweepAt(5)).toBeGreaterThan(30_000);
|
||||
expect(sweepAt(metricsConcurrencyFor(hosts, {}))).toBeLessThan(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostPollCache", () => {
|
||||
|
||||
@@ -4,35 +4,47 @@ import {
|
||||
findWorstMountIndex,
|
||||
buildFilesystemList,
|
||||
selectPrimaryFilesystem,
|
||||
filterExcludedFilesystems,
|
||||
mergeMonitoredFilesystems,
|
||||
} from "../../../../hosts/metrics/widgets/disk-collector.js";
|
||||
|
||||
describe("parseDfLines", () => {
|
||||
it("parses df -P output into rows", () => {
|
||||
it("parses df -T -P output into rows", () => {
|
||||
const output =
|
||||
"/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n";
|
||||
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].mount).toBe("/");
|
||||
expect(rows[0].type).toBe("ext4");
|
||||
expect(rows[1].mount).toBe("/data");
|
||||
});
|
||||
|
||||
it("filters out pseudo filesystems", () => {
|
||||
const output =
|
||||
"tmpfs 8000 0 8000 0% /dev/shm\n" +
|
||||
"overlay 100 50 50 50% /\n" +
|
||||
"/dev/sda1 100 50 50 50% /mnt/data\n";
|
||||
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
|
||||
"overlay overlay 100 50 50 50% /\n" +
|
||||
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].mount).toBe("/mnt/data");
|
||||
});
|
||||
|
||||
it("captures the filesystem type for network shares", () => {
|
||||
const output =
|
||||
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
|
||||
const rows = parseDfLines(output);
|
||||
expect(rows[0].type).toBe("nfs4");
|
||||
expect(rows[1].type).toBe("cifs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findWorstMountIndex", () => {
|
||||
it("picks the most-utilized mount, not just the first row", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n",
|
||||
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
|
||||
);
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(1);
|
||||
@@ -41,14 +53,15 @@ describe("findWorstMountIndex", () => {
|
||||
});
|
||||
|
||||
it("falls back to the only mount available", () => {
|
||||
const rows = parseDfLines("/dev/sda1 100 30 70 30% /\n");
|
||||
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(0);
|
||||
});
|
||||
|
||||
it("skips rows with invalid or zero totals", () => {
|
||||
const rows = parseDfLines(
|
||||
"/dev/sda1 0 0 0 0% /broken\n" + "/dev/sda2 100 40 60 40% /ok\n",
|
||||
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
|
||||
"/dev/sda2 ext4 100 40 60 40% /ok\n",
|
||||
);
|
||||
const worst = findWorstMountIndex(rows);
|
||||
expect(worst.index).toBe(1);
|
||||
@@ -62,11 +75,11 @@ describe("findWorstMountIndex", () => {
|
||||
});
|
||||
|
||||
const BYTES_OUTPUT =
|
||||
"/dev/nvme0n1p2 1000 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 2000 1900 100 95% /data\n";
|
||||
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
|
||||
const HUMAN_OUTPUT =
|
||||
"/dev/nvme0n1p2 1.0K 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 2.0K 1.9K 100 95% /data\n";
|
||||
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
|
||||
|
||||
describe("buildFilesystemList", () => {
|
||||
it("returns every real filesystem with byte maths and human strings", () => {
|
||||
@@ -77,6 +90,7 @@ describe("buildFilesystemList", () => {
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]).toMatchObject({
|
||||
mount: "/",
|
||||
type: "ext4",
|
||||
percent: 40,
|
||||
usedHuman: "400",
|
||||
totalHuman: "1.0K",
|
||||
@@ -90,7 +104,7 @@ describe("buildFilesystemList", () => {
|
||||
it("matches human rows by mount when the row counts differ", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines("/dev/nvme1n1p1 2.0K 1.9K 100 95% /data\n"),
|
||||
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
|
||||
);
|
||||
expect(list[0].totalHuman).toBeNull();
|
||||
expect(list[1].totalHuman).toBe("2.0K");
|
||||
@@ -98,7 +112,9 @@ describe("buildFilesystemList", () => {
|
||||
|
||||
it("drops filesystems with a zero or invalid total", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 0 0 0 0% /broken\n/dev/sda2 100 40 60 40% /ok\n"),
|
||||
parseDfLines(
|
||||
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
expect(list).toHaveLength(1);
|
||||
@@ -118,8 +134,8 @@ describe("selectPrimaryFilesystem", () => {
|
||||
it("falls back to the most-utilized mount when there is no root", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 1000 100 900 10% /mnt/a\n" +
|
||||
"/dev/sda2 1000 800 200 80% /mnt/b\n",
|
||||
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
|
||||
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
@@ -130,3 +146,79 @@ describe("selectPrimaryFilesystem", () => {
|
||||
expect(selectPrimaryFilesystem([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterExcludedFilesystems", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 ext4 1000 400 600 40% /\n" +
|
||||
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
it("returns the same list when no mounts are excluded", () => {
|
||||
expect(filterExcludedFilesystems(list)).toHaveLength(3);
|
||||
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("excludes an exact mount path match", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
|
||||
});
|
||||
|
||||
it("excludes by filesystem type substring, case-insensitively", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["NFS"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
|
||||
});
|
||||
|
||||
it("supports excluding multiple network filesystem types at once", () => {
|
||||
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
|
||||
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
|
||||
});
|
||||
|
||||
it("ignores blank/whitespace-only entries", () => {
|
||||
const filtered = filterExcludedFilesystems(list, [" ", ""]);
|
||||
expect(filtered).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeMonitoredFilesystems", () => {
|
||||
it("adds an arbitrary path with a user label", () => {
|
||||
const detected = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
|
||||
[],
|
||||
);
|
||||
const custom = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
|
||||
[],
|
||||
);
|
||||
const result = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
[{ path: "/config", label: "Home Assistant" }],
|
||||
custom,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1]).toMatchObject({
|
||||
mount: "/config",
|
||||
label: "Home Assistant",
|
||||
totalBytes: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("labels a path that is already a detected mount", () => {
|
||||
const detected = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
|
||||
[],
|
||||
);
|
||||
const result = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
[{ path: "/data", label: "Media" }],
|
||||
detected,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].label).toBe("Media");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
counterRate,
|
||||
parseNetworkCounters,
|
||||
} from "../../../../hosts/metrics/widgets/network-collector.js";
|
||||
|
||||
const PROC_NET = `Inter-| Receive | Transmit
|
||||
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
||||
eth0: 1024 1 0 0 0 0 0 0 2048 2 0 0 0 0 0 0
|
||||
lo: 4096 4 0 0 0 0 0 0 4096 4 0 0 0 0 0 0`;
|
||||
|
||||
describe("network counters", () => {
|
||||
it("parses Linux proc counters", () => {
|
||||
expect(parseNetworkCounters(PROC_NET).get("eth0")).toEqual({
|
||||
rx: "1024",
|
||||
tx: "2048",
|
||||
});
|
||||
});
|
||||
|
||||
it("calculates bytes per second and rejects counter resets", () => {
|
||||
expect(counterRate("1000", "2500", 0.5)).toBe(3000);
|
||||
expect(counterRate("2500", "1000", 0.5)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hostAddressMismatch,
|
||||
HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||
HostAddressMismatchError,
|
||||
HostNotOnThisServerError,
|
||||
normalizeHostAddress,
|
||||
} from "../../../hosts/terminal/host-identity.js";
|
||||
|
||||
/**
|
||||
* The desktop app lists hosts out of its own embedded database and identifies
|
||||
* them to the backend by numeric row id. With the connection origin set to
|
||||
* "Remote server" that id is resolved against the sync server's `ssh_data`
|
||||
* instead, whose autoincrement ids drift apart from the client's as soon as
|
||||
* the two sides accumulate inserts and deletes in a different order.
|
||||
*
|
||||
* The resolved row supplies the address, the credentials, the jump hosts and
|
||||
* the stored host key, so the session opened on whichever machine owned that
|
||||
* id on the server — the host list stayed correct the whole time, and nothing
|
||||
* announced the substitution.
|
||||
*/
|
||||
describe("hostAddressMismatch", () => {
|
||||
it("refuses an id that resolves to a different machine", () => {
|
||||
expect(hostAddressMismatch("10.0.0.7", "10.0.0.9")).toBe(true);
|
||||
expect(hostAddressMismatch("aeza.example.com", "rpi.example.com")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows the ordinary case where both sides agree", () => {
|
||||
expect(hostAddressMismatch("10.0.0.7", "10.0.0.7")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trip over how an address is written", () => {
|
||||
// The client strips brackets off IPv6 literals before connecting; the
|
||||
// stored row keeps them. Same machine either way.
|
||||
expect(hostAddressMismatch("2001:db8::1", "[2001:db8::1]")).toBe(false);
|
||||
expect(hostAddressMismatch("Host.Example.COM", "host.example.com")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(hostAddressMismatch("10.0.0.7", " 10.0.0.7 ")).toBe(false);
|
||||
});
|
||||
|
||||
it("stays out of the way when the server has no address to compare", () => {
|
||||
// Nothing stored server-side: the caller falls back to what the client
|
||||
// supplied, as it always has. Refusing here would break every setup that
|
||||
// passes host details inline.
|
||||
expect(hostAddressMismatch("10.0.0.7", undefined)).toBe(false);
|
||||
expect(hostAddressMismatch("10.0.0.7", null)).toBe(false);
|
||||
expect(hostAddressMismatch("10.0.0.7", "")).toBe(false);
|
||||
expect(hostAddressMismatch("10.0.0.7", " ")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses when the client sent nothing but the server resolved a host", () => {
|
||||
// An id alone must not be enough to pick a machine.
|
||||
expect(hostAddressMismatch(undefined, "10.0.0.9")).toBe(true);
|
||||
expect(hostAddressMismatch("", "10.0.0.9")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostAddressMismatchError", () => {
|
||||
it("survives the catch blocks that swallow resolution failures", () => {
|
||||
// SFTP host resolution sits inside "failed to resolve credentials, carry
|
||||
// on" handlers. Continuing is precisely what must not happen here, so
|
||||
// those catches rethrow this type -- which only works if it is
|
||||
// recognisable with instanceof after being thrown.
|
||||
const rethrow = () => {
|
||||
try {
|
||||
throw new HostAddressMismatchError();
|
||||
} catch (error) {
|
||||
if (error instanceof HostAddressMismatchError) throw error;
|
||||
return "swallowed";
|
||||
}
|
||||
};
|
||||
|
||||
expect(rethrow).toThrow(HostAddressMismatchError);
|
||||
expect(rethrow).toThrow(HOST_ADDRESS_MISMATCH_MESSAGE);
|
||||
});
|
||||
|
||||
it("tells the user which of their settings to change", () => {
|
||||
// The message is the only actionable thing they get; the workaround has
|
||||
// to be in it.
|
||||
expect(HOST_ADDRESS_MISMATCH_MESSAGE).toContain("This device");
|
||||
expect(HOST_ADDRESS_MISMATCH_MESSAGE).toContain("full sync");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostNotOnThisServerError", () => {
|
||||
it("is distinguishable from a mismatch, and survives a rethrow", () => {
|
||||
// Different remedies: an unknown host needs syncing across, a mismatched
|
||||
// one needs a different origin. The SFTP catches rethrow both.
|
||||
const thrown = (() => {
|
||||
try {
|
||||
throw new HostNotOnThisServerError();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
})();
|
||||
|
||||
expect(thrown).toBeInstanceOf(HostNotOnThisServerError);
|
||||
expect(thrown).not.toBeInstanceOf(HostAddressMismatchError);
|
||||
expect((thrown as Error).message).toBe(HOST_NOT_ON_THIS_SERVER_MESSAGE);
|
||||
});
|
||||
|
||||
it("tells the user to sync rather than to switch origin", () => {
|
||||
expect(HOST_NOT_ON_THIS_SERVER_MESSAGE).toContain("sync");
|
||||
expect(HOST_NOT_ON_THIS_SERVER_MESSAGE).toContain("This device");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeHostAddress", () => {
|
||||
it("keeps only what identifies the host", () => {
|
||||
expect(normalizeHostAddress("[2001:db8::1]")).toBe("2001:db8::1");
|
||||
expect(normalizeHostAddress(" Example.COM ")).toBe("example.com");
|
||||
});
|
||||
|
||||
it("treats anything that is not a string as no address", () => {
|
||||
expect(normalizeHostAddress(undefined)).toBe("");
|
||||
expect(normalizeHostAddress(null)).toBe("");
|
||||
expect(normalizeHostAddress(42)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { Client } from "ssh2";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -8,24 +9,42 @@ import {
|
||||
} from "../../../hosts/tmux/helper.js";
|
||||
|
||||
describe("tmux command path handling", () => {
|
||||
it("adds common non-login shell tmux paths", () => {
|
||||
const command = withTmuxPath("command -v tmux");
|
||||
|
||||
expect(command).toMatch(/^\/bin\/sh -c '/);
|
||||
expect(command).toContain("/opt/homebrew/bin");
|
||||
expect(command).toContain("/usr/local/bin");
|
||||
expect(command).toContain("/opt/bin");
|
||||
expect(command).toContain("/usr/pkg/bin");
|
||||
expect(command).toContain(":$PATH; export PATH; command -v tmux");
|
||||
});
|
||||
|
||||
it("wraps tmux invocations with the same path", () => {
|
||||
expect(tmuxCommand("list-sessions")).toMatch(
|
||||
/^\/bin\/sh -c 'PATH=.*:\$PATH; export PATH; tmux list-sessions'$/,
|
||||
it("prepends all non-login tmux paths while preserving inherited PATH", () => {
|
||||
expect(withTmuxPath("command -v tmux")).toBe(
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; command -v tmux'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("detects suffixed tmux versions without parsing the version number", async () => {
|
||||
it("shell-escapes embedded single quotes in wrapped commands", () => {
|
||||
// Asserted as a string so the escaping rule is covered everywhere. The
|
||||
// round-trip below proves it against a real parser, but only where one
|
||||
// exists -- see the note there.
|
||||
expect(withTmuxPath(`printf '%s' "can't"`)).toBe(
|
||||
"/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:\"$PATH\"; export PATH; printf '\\''%s'\\'' \"can'\\''t\"'",
|
||||
);
|
||||
});
|
||||
|
||||
// /bin/sh is not on Windows, and Windows is a supported platform for the
|
||||
// desktop app -- contributors run `npm test` there. CI is ubuntu-only, so it
|
||||
// would never notice this failing.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"produces a command a real shell parses back to the original",
|
||||
() => {
|
||||
const command = withTmuxPath(`printf '%s' "can't"`);
|
||||
|
||||
expect(
|
||||
execFileSync("/bin/sh", ["-c", command], { encoding: "utf8" }),
|
||||
).toBe("can't");
|
||||
},
|
||||
);
|
||||
|
||||
it("runs every tmux invocation in UTF-8 mode through the path wrapper", () => {
|
||||
expect(tmuxCommand("list-sessions")).toBe(
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u list-sessions'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("detects tmux with the UTF-8 wrapper", async () => {
|
||||
const commands: string[] = [];
|
||||
const conn = {
|
||||
exec(command: string, callback: (error: null, stream: never) => void) {
|
||||
@@ -51,6 +70,9 @@ describe("tmux command path handling", () => {
|
||||
available: true,
|
||||
sessions: [],
|
||||
});
|
||||
expect(commands[0]).toContain("tmux -V");
|
||||
expect(commands).toEqual([
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u -V'`,
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null'`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("getRequestMeta", () => {
|
||||
"user-agent": "TestAgent/1.0",
|
||||
},
|
||||
ip: "127.0.0.1",
|
||||
socket: {},
|
||||
};
|
||||
const meta = getRequestMeta(req as never);
|
||||
expect(meta.ipAddress).toBe("10.0.0.1");
|
||||
@@ -78,8 +79,39 @@ describe("getRequestMeta", () => {
|
||||
const req = {
|
||||
headers: { "user-agent": "Bot/2" },
|
||||
ip: "192.168.1.1",
|
||||
socket: {},
|
||||
};
|
||||
const meta = getRequestMeta(req as never);
|
||||
expect(meta.ipAddress).toBe("192.168.1.1");
|
||||
});
|
||||
|
||||
it("splits and trims a forwarded header sent as an array", () => {
|
||||
const req = {
|
||||
headers: {
|
||||
"x-forwarded-for": ["10.0.0.1, 10.0.0.2"],
|
||||
"user-agent": "TestAgent/1.0",
|
||||
},
|
||||
socket: {},
|
||||
};
|
||||
const meta = getRequestMeta(req as never);
|
||||
expect(meta.ipAddress).toBe("10.0.0.1");
|
||||
});
|
||||
|
||||
it("falls back to the socket peer when there is no forwarded header or req.ip", () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
socket: { remoteAddress: "203.0.113.9" },
|
||||
};
|
||||
const meta = getRequestMeta(req as never);
|
||||
expect(meta.ipAddress).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
it("returns 'unknown' rather than an empty string when no IP info exists", () => {
|
||||
const req = {
|
||||
headers: {},
|
||||
socket: {},
|
||||
};
|
||||
const meta = getRequestMeta(req as never);
|
||||
expect(meta.ipAddress).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import type { Server } from "http";
|
||||
import { createCompressionMiddleware } from "../../utils/compression-config.js";
|
||||
|
||||
/** A body big enough to clear the size threshold, and repetitive like real JSON. */
|
||||
function bigJson(): Record<string, unknown>[] {
|
||||
return Array.from({ length: 200 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `prod-app-server-${i}`,
|
||||
ip: `10.20.0.${i % 254}`,
|
||||
folder: "Production / US-East / App Tier",
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("createCompressionMiddleware", () => {
|
||||
let server: Server | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
server = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function startServer(
|
||||
configure: (app: express.Express) => void,
|
||||
): Promise<string> {
|
||||
const app = express();
|
||||
app.use(createCompressionMiddleware());
|
||||
configure(app);
|
||||
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const s = app.listen(0, "127.0.0.1", () => resolve(s));
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected a TCP address");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
it("gzips a large JSON response", async () => {
|
||||
const base = await startServer((app) => {
|
||||
app.get("/big", (_req, res) => res.json(bigJson()));
|
||||
});
|
||||
|
||||
const res = await fetch(`${base}/big`, {
|
||||
headers: { "Accept-Encoding": "gzip" },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBe("gzip");
|
||||
// fetch transparently decodes, so the parsed body must still be intact.
|
||||
expect(await res.json()).toHaveLength(200);
|
||||
});
|
||||
|
||||
it("substantially shrinks the host-list shaped payload", async () => {
|
||||
const body = JSON.stringify(bigJson());
|
||||
const base = await startServer((app) => {
|
||||
app.get("/big", (_req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(body);
|
||||
});
|
||||
});
|
||||
|
||||
// A gzipped response is sent chunked, so there is no content-length to
|
||||
// read; measure the encoded bytes off the socket instead.
|
||||
const res = await fetch(`${base}/big`, {
|
||||
headers: { "Accept-Encoding": "gzip" },
|
||||
});
|
||||
expect(res.headers.get("content-encoding")).toBe("gzip");
|
||||
|
||||
const raw = await new Promise<Buffer>((resolve, reject) => {
|
||||
import("http").then(({ get }) => {
|
||||
get(
|
||||
`${base}/big`,
|
||||
{ headers: { "Accept-Encoding": "gzip" } },
|
||||
(response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (c: Buffer) => chunks.push(c));
|
||||
response.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
response.on("error", reject);
|
||||
},
|
||||
).on("error", reject);
|
||||
}, reject);
|
||||
});
|
||||
|
||||
// Repetitive JSON should compress by well over half.
|
||||
expect(raw.length).toBeGreaterThan(0);
|
||||
expect(raw.length).toBeLessThan(Buffer.byteLength(body) / 2);
|
||||
});
|
||||
|
||||
it("leaves a small response uncompressed", async () => {
|
||||
const base = await startServer((app) => {
|
||||
app.get("/small", (_req, res) => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
const res = await fetch(`${base}/small`, {
|
||||
headers: { "Accept-Encoding": "gzip" },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBeNull();
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("does not compress an event stream, which must not be buffered", async () => {
|
||||
const base = await startServer((app) => {
|
||||
app.get("/stream", (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.write(`data: ${"x".repeat(8192)}\n\n`);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
const res = await fetch(`${base}/stream`, {
|
||||
headers: { "Accept-Encoding": "gzip" },
|
||||
});
|
||||
await res.text();
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not compress a binary download stream", async () => {
|
||||
const base = await startServer((app) => {
|
||||
app.get("/download", (_req, res) => {
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.end(Buffer.alloc(16384, 1));
|
||||
});
|
||||
});
|
||||
|
||||
const res = await fetch(`${base}/download`, {
|
||||
headers: { "Accept-Encoding": "gzip" },
|
||||
});
|
||||
await res.arrayBuffer();
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBeNull();
|
||||
});
|
||||
|
||||
it("honours an explicit opt-out header", async () => {
|
||||
const base = await startServer((app) => {
|
||||
app.get("/big", (_req, res) => res.json(bigJson()));
|
||||
});
|
||||
|
||||
const res = await fetch(`${base}/big`, {
|
||||
headers: { "Accept-Encoding": "gzip", "x-no-compression": "1" },
|
||||
});
|
||||
await res.json();
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves the body alone for a client that cannot accept gzip", async () => {
|
||||
const base = await startServer((app) => {
|
||||
app.get("/big", (_req, res) => res.json(bigJson()));
|
||||
});
|
||||
|
||||
const res = await fetch(`${base}/big`, {
|
||||
headers: { "Accept-Encoding": "identity" },
|
||||
});
|
||||
|
||||
expect(res.headers.get("content-encoding")).toBeNull();
|
||||
expect(await res.json()).toHaveLength(200);
|
||||
});
|
||||
});
|
||||
+29
@@ -86,6 +86,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
state.sqlite.close();
|
||||
delete process.env.DATABASE_DIALECT;
|
||||
});
|
||||
|
||||
describe("runLegacySharedSshAuthOptInMigration", () => {
|
||||
@@ -161,4 +162,32 @@ describe("runLegacySharedSshAuthOptInMigration", () => {
|
||||
).toEqual({ share_ssh_auth: 0 });
|
||||
expect(state.resyncedHostIds).toEqual([3]);
|
||||
});
|
||||
|
||||
// The behavior being preserved belongs to releases that only ran on SQLite,
|
||||
// and the probes below it are sqlite_master specific. Without the guard this
|
||||
// logged a failure on every boot against a remote engine.
|
||||
it.each(["postgres", "mysql"])("does nothing on %s", async (dialect) => {
|
||||
process.env.DATABASE_DIALECT = dialect;
|
||||
|
||||
await expect(runLegacySharedSshAuthOptInMigration()).resolves.toEqual({
|
||||
enabled: 0,
|
||||
resynced: 0,
|
||||
skipped: 0,
|
||||
});
|
||||
|
||||
expect(
|
||||
state.sqlite
|
||||
.prepare("SELECT id, share_ssh_auth FROM ssh_data ORDER BY id")
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ id: 1, share_ssh_auth: 0 },
|
||||
{ id: 2, share_ssh_auth: 0 },
|
||||
{ id: 3, share_ssh_auth: 1 },
|
||||
{ id: 4, share_ssh_auth: 0 },
|
||||
{ id: 5, share_ssh_auth: 0 },
|
||||
]);
|
||||
expect(state.resyncedHostIds).toEqual([]);
|
||||
expect(state.settings.has("legacy_shared_ssh_auth_opt_in_v1")).toBe(false);
|
||||
expect(state.saves).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
state.sqlite.close();
|
||||
delete process.env.DATABASE_DIALECT;
|
||||
});
|
||||
|
||||
describe("runPrivateSharedSshAuthMigration", () => {
|
||||
@@ -101,4 +102,20 @@ describe("runPrivateSharedSshAuthMigration", () => {
|
||||
).toEqual({ count: 4 });
|
||||
expect(state.saves).toHaveLength(0);
|
||||
});
|
||||
|
||||
// These snapshots only exist in databases written before Postgres and MySQL
|
||||
// were supported. Without the guard this reached for a SQLite handle that is
|
||||
// not there and logged a failure on every boot.
|
||||
it.each(["postgres", "mysql"])("does nothing on %s", async (dialect) => {
|
||||
process.env.DATABASE_DIALECT = dialect;
|
||||
|
||||
expect(await runPrivateSharedSshAuthMigration()).toBeNull();
|
||||
expect(
|
||||
state.sqlite
|
||||
.prepare("SELECT COUNT(*) AS count FROM shared_host_secrets")
|
||||
.get(),
|
||||
).toEqual({ count: 4 });
|
||||
expect(state.settings.has("private_shared_ssh_auth_v1")).toBe(false);
|
||||
expect(state.saves).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,11 @@ const accessState = vi.hoisted(() => ({
|
||||
} | null,
|
||||
touched: [] as number[],
|
||||
adminIds: new Set<string>(),
|
||||
rolePermissionCalls: 0,
|
||||
rolePermissions: [] as { permissions: string }[],
|
||||
ownedHostIds: new Set<number>(),
|
||||
visibleGrants: [] as { hostId: number }[],
|
||||
ownedQueryCalls: 0,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -31,8 +36,13 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
isHostOwnedByUser: async (_hostId: number, userId: string) =>
|
||||
userId === accessState.ownerId,
|
||||
findHostOwnerId: async () => accessState.ownerId,
|
||||
listOwnedHostIds: async () => {
|
||||
accessState.ownedQueryCalls += 1;
|
||||
return accessState.ownedHostIds;
|
||||
},
|
||||
}),
|
||||
createCurrentRbacAccessRepository: () => ({
|
||||
listVisibleHostAccessEntries: async () => accessState.visibleGrants,
|
||||
findActiveHostAccess: async () => accessState.grant,
|
||||
touchHostAccess: async (id: number) => {
|
||||
accessState.touched.push(id);
|
||||
@@ -41,7 +51,10 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
listUserRoleIds: async () => [],
|
||||
listUserRolePermissions: async () => [],
|
||||
listUserRolePermissions: async () => {
|
||||
accessState.rolePermissionCalls += 1;
|
||||
return accessState.rolePermissions;
|
||||
},
|
||||
userHasAnyRoleName: async () => false,
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
@@ -195,3 +208,160 @@ describe("PermissionManager.canAccessHost level hierarchy", () => {
|
||||
expect(info.isAdminBypass).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PermissionManager.getUserPermissions caching", () => {
|
||||
let manager: PermissionManagerInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
manager = PermissionManager.getInstance();
|
||||
accessState.rolePermissionCalls = 0;
|
||||
accessState.rolePermissions = [{ permissions: '["hosts.read"]' }];
|
||||
manager.invalidateUserPermissionCache("cache-user");
|
||||
});
|
||||
|
||||
it("serves repeat lookups from cache instead of re-querying roles", async () => {
|
||||
expect(await manager.getUserPermissions("cache-user")).toEqual([
|
||||
"hosts.read",
|
||||
]);
|
||||
expect(await manager.getUserPermissions("cache-user")).toEqual([
|
||||
"hosts.read",
|
||||
]);
|
||||
|
||||
expect(accessState.rolePermissionCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("re-reads roles after an explicit invalidation", async () => {
|
||||
await manager.getUserPermissions("cache-user");
|
||||
manager.invalidateUserPermissionCache("cache-user");
|
||||
accessState.rolePermissions = [{ permissions: '["hosts.write"]' }];
|
||||
|
||||
expect(await manager.getUserPermissions("cache-user")).toEqual([
|
||||
"hosts.write",
|
||||
]);
|
||||
expect(accessState.rolePermissionCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("expires an entry once its own TTL has passed", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await manager.getUserPermissions("cache-user");
|
||||
// Just past the 5 minute TTL.
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
await manager.getUserPermissions("cache-user");
|
||||
|
||||
expect(accessState.rolePermissionCalls).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a still-fresh entry when the sweep runs", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await manager.getUserPermissions("cache-user");
|
||||
// Fire the periodic sweep without crossing this entry's own TTL. The
|
||||
// old implementation cleared the whole map here, expiring every active
|
||||
// user at once.
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 - 1000);
|
||||
await manager.getUserPermissions("cache-user");
|
||||
|
||||
expect(accessState.rolePermissionCalls).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty set rather than throwing when role lookup fails", async () => {
|
||||
manager.invalidateUserPermissionCache("boom-user");
|
||||
accessState.rolePermissions = [{ permissions: "not-json" }];
|
||||
|
||||
expect(await manager.getUserPermissions("boom-user")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PermissionManager.filterAccessibleHostIds", () => {
|
||||
let manager: PermissionManagerInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
manager = PermissionManager.getInstance();
|
||||
accessState.adminIds = new Set();
|
||||
accessState.ownedHostIds = new Set();
|
||||
accessState.visibleGrants = [];
|
||||
accessState.ownedQueryCalls = 0;
|
||||
});
|
||||
|
||||
it("keeps hosts the user owns", async () => {
|
||||
accessState.ownedHostIds = new Set([1, 2]);
|
||||
|
||||
const allowed = await manager.filterAccessibleHostIds("u1", [1, 2, 3]);
|
||||
|
||||
expect([...allowed].sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("keeps hosts shared with the user", async () => {
|
||||
accessState.visibleGrants = [{ hostId: 7 }];
|
||||
|
||||
const allowed = await manager.filterAccessibleHostIds("u1", [7, 8]);
|
||||
|
||||
expect([...allowed]).toEqual([7]);
|
||||
});
|
||||
|
||||
it("combines owned and shared without duplicating", async () => {
|
||||
accessState.ownedHostIds = new Set([1]);
|
||||
accessState.visibleGrants = [{ hostId: 1 }, { hostId: 2 }];
|
||||
|
||||
const allowed = await manager.filterAccessibleHostIds("u1", [1, 2, 3]);
|
||||
|
||||
expect([...allowed].sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("excludes another tenant's hosts", async () => {
|
||||
accessState.ownedHostIds = new Set([1]);
|
||||
|
||||
const allowed = await manager.filterAccessibleHostIds("u1", [1, 99, 100]);
|
||||
|
||||
expect(allowed.has(99)).toBe(false);
|
||||
expect(allowed.has(100)).toBe(false);
|
||||
});
|
||||
|
||||
it("gives an admin every host without per-host lookups", async () => {
|
||||
accessState.adminIds = new Set(["admin1"]);
|
||||
|
||||
const allowed = await manager.filterAccessibleHostIds(
|
||||
"admin1",
|
||||
[1, 2, 3, 4],
|
||||
);
|
||||
|
||||
expect([...allowed].sort()).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("resolves the whole fleet with a single owned-hosts query", async () => {
|
||||
accessState.ownedHostIds = new Set(
|
||||
Array.from({ length: 500 }, (_, i) => i + 1),
|
||||
);
|
||||
const ids = Array.from({ length: 500 }, (_, i) => i + 1);
|
||||
|
||||
const allowed = await manager.filterAccessibleHostIds("u1", ids);
|
||||
|
||||
expect(allowed.size).toBe(500);
|
||||
// The point of the batch path: cost does not scale with host count.
|
||||
expect(accessState.ownedQueryCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("short-circuits an empty list without querying", async () => {
|
||||
const allowed = await manager.filterAccessibleHostIds("u1", []);
|
||||
|
||||
expect(allowed.size).toBe(0);
|
||||
expect(accessState.ownedQueryCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("fails closed when the lookup throws", async () => {
|
||||
accessState.ownedHostIds = null as unknown as Set<number>;
|
||||
|
||||
const allowed = await manager.filterAccessibleHostIds("u1", [1, 2]);
|
||||
|
||||
expect(allowed.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getClientIp,
|
||||
getRequestBasePath,
|
||||
getRequestBaseUrl,
|
||||
getRequestBaseUrlWithForceHTTPS,
|
||||
@@ -14,6 +15,18 @@ function request(headers: Record<string, string | string[] | undefined>) {
|
||||
} as Parameters<typeof getRequestBasePath>[0];
|
||||
}
|
||||
|
||||
function requestWithSocket(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
socket: { remoteAddress?: string },
|
||||
ip?: string,
|
||||
) {
|
||||
return {
|
||||
headers,
|
||||
socket,
|
||||
ip,
|
||||
} as unknown as Parameters<typeof getClientIp>[0];
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
@@ -108,6 +121,52 @@ describe("getRequestBasePath", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClientIp", () => {
|
||||
it("prefers the leftmost X-Forwarded-For entry over the socket peer", () => {
|
||||
expect(
|
||||
getClientIp(
|
||||
requestWithSocket(
|
||||
{ "x-forwarded-for": "203.0.113.7, 10.0.0.1, 10.0.0.2" },
|
||||
{ remoteAddress: "::ffff:127.0.0.1" },
|
||||
),
|
||||
),
|
||||
).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("handles X-Forwarded-For sent as a header array", () => {
|
||||
expect(
|
||||
getClientIp(
|
||||
requestWithSocket(
|
||||
{ "x-forwarded-for": ["203.0.113.7", "10.0.0.1"] },
|
||||
{ remoteAddress: "::ffff:127.0.0.1" },
|
||||
),
|
||||
),
|
||||
).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("falls back to req.ip when there is no forwarded header", () => {
|
||||
expect(
|
||||
getClientIp(
|
||||
requestWithSocket(
|
||||
{},
|
||||
{ remoteAddress: "::ffff:127.0.0.1" },
|
||||
"198.51.100.5",
|
||||
),
|
||||
),
|
||||
).toBe("198.51.100.5");
|
||||
});
|
||||
|
||||
it("falls back to the raw socket peer when nothing else is available", () => {
|
||||
expect(
|
||||
getClientIp(requestWithSocket({}, { remoteAddress: "198.51.100.9" })),
|
||||
).toBe("198.51.100.9");
|
||||
});
|
||||
|
||||
it("returns unknown when no IP information exists at all", () => {
|
||||
expect(getClientIp(requestWithSocket({}, {}))).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRequestOrigin", () => {
|
||||
it("ignores non-numeric forwarded ports", () => {
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LookupAddress, LookupAllOptions, LookupOptions } from "dns";
|
||||
import type { LookupAddress, LookupOptions } from "dns";
|
||||
import {
|
||||
createDnsLookupHook,
|
||||
isBlockedAddress,
|
||||
@@ -47,96 +47,88 @@ describe("isBlockedAddress", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// These exercise createDnsLookupHook directly against a fake resolver,
|
||||
// bypassing fetch()/undici entirely. That's the actual code path the
|
||||
// original bug lived in — a public IPv4 address getting misclassified as
|
||||
// private — and testing it through a real Agent/fetch call would only
|
||||
// add flakiness (real TCP connects, undici's own quirks) without adding
|
||||
// coverage of the logic that actually broke.
|
||||
//
|
||||
// `lookupOptions.all` controls the *caller's* expected callback shape
|
||||
// (single address vs. full array) — this is the flag Node's happy-eyeballs
|
||||
// autoSelectFamily sets to `true`. It's independent of the internal call to
|
||||
// the underlying resolver, which the hook always forces to `all: true` so it
|
||||
// has every candidate address available to run the blocklist check against.
|
||||
function runHook(
|
||||
addresses: LookupAddress[],
|
||||
addresses: LookupAddress[] | string | undefined,
|
||||
error: NodeJS.ErrnoException | null = null,
|
||||
lookupOptions: LookupOptions = { all: true },
|
||||
) {
|
||||
const fakeLookup = vi.fn(
|
||||
(
|
||||
_host: string,
|
||||
_opts: LookupAllOptions,
|
||||
cb: (err: NodeJS.ErrnoException | null, addrs: LookupAddress[]) => void,
|
||||
) => cb(error, addresses),
|
||||
_opts: LookupOptions,
|
||||
cb: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
addrs: LookupAddress[] | string | undefined,
|
||||
family?: number,
|
||||
) => void,
|
||||
) => {
|
||||
cb(error, addresses, typeof addresses === "string" ? 4 : undefined);
|
||||
},
|
||||
);
|
||||
|
||||
const hook = createDnsLookupHook(fakeLookup);
|
||||
const callback = vi.fn();
|
||||
|
||||
hook("example.invalid", lookupOptions, callback);
|
||||
|
||||
return { callback, fakeLookup };
|
||||
}
|
||||
|
||||
// The three lookupOptions shapes a real caller can pass, and the tail args
|
||||
// (everything after the leading null/error arg) the hook must answer with
|
||||
// for each — [] for the array form Node's autoSelectFamily expects, ["", 0]
|
||||
// for the legacy single-address form. Reused as plain data across the
|
||||
// it.each tables below, matching the flat tuple style used elsewhere in
|
||||
// this test suite (see termix-id-keys.test.ts, oidc-desktop-callback.test.ts)
|
||||
// rather than nesting a parameterized describe block.
|
||||
const lookupOptionsCases: Array<[string, LookupOptions, unknown[]]> = [
|
||||
["all:true (Node's autoSelectFamily/happy-eyeballs)", { all: true }, [[]]],
|
||||
["all:false (legacy)", { all: false } as LookupOptions, ["", 0]],
|
||||
["all omitted (legacy)", {} as LookupOptions, ["", 0]],
|
||||
["all:true", { all: true }, ["", 0]],
|
||||
["all:false", { all: false }, ["", 0]],
|
||||
["all omitted", {}, ["", 0]],
|
||||
];
|
||||
|
||||
// Fixed answer used by the success table below — kept separate from
|
||||
// lookupOptionsCases because the expected tail args here are the resolved
|
||||
// address(es) themselves, not a fixed "", 0 vs [] shape.
|
||||
const publicAddresses = [
|
||||
{ address: "104.21.52.150", family: 4 },
|
||||
{ address: "2606:4700:3034::ac43:c88d", family: 6 },
|
||||
];
|
||||
const successCases: Array<[string, LookupOptions, unknown[]]> = [
|
||||
[
|
||||
"all:true (Node's autoSelectFamily/happy-eyeballs)",
|
||||
{ all: true },
|
||||
[publicAddresses],
|
||||
],
|
||||
[
|
||||
"all:false (legacy)",
|
||||
{ all: false } as LookupOptions,
|
||||
[publicAddresses[0].address, publicAddresses[0].family],
|
||||
],
|
||||
[
|
||||
"all omitted (legacy)",
|
||||
{} as LookupOptions,
|
||||
[publicAddresses[0].address, publicAddresses[0].family],
|
||||
],
|
||||
const publicAddresses: LookupAddress[] = [
|
||||
{
|
||||
address: "104.21.52.150",
|
||||
family: 4,
|
||||
},
|
||||
{
|
||||
address: "2606:4700:3034::ac43:c88d",
|
||||
family: 6,
|
||||
},
|
||||
];
|
||||
|
||||
describe("createDnsLookupHook", () => {
|
||||
it.each(successCases)(
|
||||
"returns the resolved address(es) on a fully public answer (%s)",
|
||||
(_label, lookupOptions, tailArgs) => {
|
||||
const { callback } = runHook(publicAddresses, null, lookupOptions);
|
||||
expect(callback).toHaveBeenCalledWith(null, ...tailArgs);
|
||||
},
|
||||
);
|
||||
it("allows a public IPv4 address through", () => {
|
||||
const { callback } = runHook([
|
||||
{
|
||||
address: "104.21.52.150",
|
||||
family: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
null,
|
||||
[{ address: "104.21.52.150", family: 4 }],
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(lookupOptionsCases)(
|
||||
"rejects if any address is private, including an IPv4-mapped IPv6 spoof not in first position (%s)",
|
||||
(_label, lookupOptions, tailArgs) => {
|
||||
const { callback } = runHook(
|
||||
[
|
||||
{ address: "104.21.52.150", family: 4 },
|
||||
{ address: "::ffff:192.168.1.1", family: 6 },
|
||||
{ address: "2606:4700:3034::ac43:c88d", family: 6 },
|
||||
{
|
||||
address: "104.21.52.150",
|
||||
family: 4,
|
||||
},
|
||||
{
|
||||
address: "::ffff:192.168.1.1",
|
||||
family: 6,
|
||||
},
|
||||
{
|
||||
address: "2606:4700:3034::ac43:c88d",
|
||||
family: 6,
|
||||
},
|
||||
],
|
||||
null,
|
||||
lookupOptions,
|
||||
);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Private destinations are not allowed",
|
||||
@@ -146,29 +138,57 @@ describe("createDnsLookupHook", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lookupOptionsCases)(
|
||||
"rejects with a distinct error when DNS returns no addresses (%s)",
|
||||
(_label, lookupOptions, tailArgs) => {
|
||||
const { callback } = runHook([], null, lookupOptions);
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DNS resolution returned no addresses",
|
||||
}),
|
||||
...tailArgs,
|
||||
);
|
||||
},
|
||||
);
|
||||
it("returns a single lookup result when all is false", () => {
|
||||
const { callback } = runHook("104.21.52.150", null, { all: false });
|
||||
|
||||
it.each(lookupOptionsCases)(
|
||||
"propagates a real DNS lookup error untouched (%s)",
|
||||
(_label, lookupOptions, tailArgs) => {
|
||||
const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND"), {
|
||||
code: "ENOTFOUND",
|
||||
});
|
||||
const { callback } = runHook([], dnsError, lookupOptions);
|
||||
expect(callback).toHaveBeenCalledWith(dnsError, ...tailArgs);
|
||||
},
|
||||
);
|
||||
expect(callback).toHaveBeenCalledWith(null, "104.21.52.150", 4);
|
||||
});
|
||||
|
||||
it("returns a single lookup result when all is omitted", () => {
|
||||
const { callback } = runHook("104.21.52.150", null, {});
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(null, "104.21.52.150", 4);
|
||||
});
|
||||
|
||||
it("returns all lookup results when all is true", () => {
|
||||
const { callback } = runHook(publicAddresses, null, { all: true });
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(null, publicAddresses, 0);
|
||||
});
|
||||
|
||||
it("rejects invalid single lookup results with a DNS lookup error", () => {
|
||||
const { callback } = runHook(undefined, null, { all: false });
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DNS lookup returned invalid address",
|
||||
}),
|
||||
"",
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with a distinct error when DNS returns no addresses", () => {
|
||||
const { callback } = runHook([]);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DNS resolution returned no addresses",
|
||||
}),
|
||||
"",
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates a real DNS lookup error untouched", () => {
|
||||
const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND"), {
|
||||
code: "ENOTFOUND",
|
||||
});
|
||||
|
||||
const { callback } = runHook([], dnsError);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(dnsError, "", 0);
|
||||
});
|
||||
|
||||
it("always asks the underlying resolver for all:true regardless of the caller's option", () => {
|
||||
const { fakeLookup } = runHook(
|
||||
@@ -176,9 +196,15 @@ describe("createDnsLookupHook", () => {
|
||||
null,
|
||||
{ all: false },
|
||||
);
|
||||
|
||||
console.log(fakeLookup.mock.calls);
|
||||
|
||||
expect(fakeLookup).toHaveBeenCalledWith(
|
||||
"example.invalid",
|
||||
expect.objectContaining({ all: true, verbatim: true }),
|
||||
expect.objectContaining({
|
||||
all: true,
|
||||
verbatim: true,
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getTrustedProxyAuthConfig,
|
||||
isTrustedProxyAddress,
|
||||
parseTrustedProxyRoleMap,
|
||||
resolveTrustedProxyRoles,
|
||||
} from "../../utils/trusted-proxy-auth.js";
|
||||
|
||||
describe("trusted proxy authentication config", () => {
|
||||
it("requires an explicit proxy allowlist and role map", () => {
|
||||
expect(() =>
|
||||
getTrustedProxyAuthConfig({ TRUSTED_PROXY_AUTH_ENABLED: "true" }),
|
||||
).toThrow(/requires/);
|
||||
expect(() =>
|
||||
getTrustedProxyAuthConfig({
|
||||
TRUSTED_PROXY_AUTH_ENABLED: "true",
|
||||
TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "not-a-cidr",
|
||||
TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":"user"}',
|
||||
}),
|
||||
).toThrow(/Invalid trusted proxy/);
|
||||
});
|
||||
|
||||
it("matches exact addresses, CIDRs, and IPv4-mapped addresses", () => {
|
||||
const trusted = ["10.20.0.0/16", "2001:db8::/32"];
|
||||
expect(isTrustedProxyAddress("10.20.1.4", trusted)).toBe(true);
|
||||
expect(isTrustedProxyAddress("::ffff:10.20.1.4", trusted)).toBe(true);
|
||||
expect(isTrustedProxyAddress("10.21.1.4", trusted)).toBe(false);
|
||||
expect(isTrustedProxyAddress("2001:db8::5", trusted)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed when a supplied external role is not mapped", () => {
|
||||
const roleMap = parseTrustedProxyRoleMap(
|
||||
JSON.stringify({ operators: ["operator"], viewers: "readonly" }),
|
||||
);
|
||||
expect(resolveTrustedProxyRoles("operators, viewers", roleMap)).toEqual([
|
||||
"operator",
|
||||
"readonly",
|
||||
]);
|
||||
expect(resolveTrustedProxyRoles("operators, admins", roleMap)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
detectPlatform,
|
||||
parseUserAgent,
|
||||
generateDeviceFingerprint,
|
||||
getDeviceId,
|
||||
} from "../../utils/user-agent-parser.js";
|
||||
|
||||
function reqWith(headers: Record<string, string>): Request {
|
||||
@@ -98,50 +99,81 @@ describe("parseUserAgent", () => {
|
||||
});
|
||||
|
||||
describe("generateDeviceFingerprint", () => {
|
||||
it("is stable across minor browser version bumps on web", () => {
|
||||
const a = generateDeviceFingerprint({
|
||||
type: "web",
|
||||
browser: "Chrome",
|
||||
version: "120.5",
|
||||
os: "Windows 10/11",
|
||||
deviceInfo: "Chrome 120.5 on Windows 10/11",
|
||||
});
|
||||
const b = generateDeviceFingerprint({
|
||||
type: "web",
|
||||
browser: "Chrome",
|
||||
version: "120.9",
|
||||
os: "Windows 10/11",
|
||||
deviceInfo: "Chrome 120.9 on Windows 10/11",
|
||||
});
|
||||
it("is stable for the same client device id", () => {
|
||||
const a = generateDeviceFingerprint(
|
||||
{
|
||||
type: "web",
|
||||
browser: "Chrome",
|
||||
version: "120.5",
|
||||
os: "Windows 10/11",
|
||||
deviceInfo: "Chrome 120.5 on Windows 10/11",
|
||||
},
|
||||
"a".repeat(64),
|
||||
);
|
||||
const b = generateDeviceFingerprint(
|
||||
{
|
||||
type: "web",
|
||||
browser: "Chrome",
|
||||
version: "121.9",
|
||||
os: "Windows 10/11",
|
||||
deviceInfo: "Chrome 121.9 on Windows 10/11",
|
||||
},
|
||||
"a".repeat(64),
|
||||
);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("differs across major browser versions on web", () => {
|
||||
const a = generateDeviceFingerprint({
|
||||
type: "web",
|
||||
browser: "Chrome",
|
||||
version: "120.0",
|
||||
os: "Windows 10/11",
|
||||
deviceInfo: "",
|
||||
});
|
||||
const b = generateDeviceFingerprint({
|
||||
type: "web",
|
||||
browser: "Chrome",
|
||||
version: "121.0",
|
||||
os: "Windows 10/11",
|
||||
deviceInfo: "",
|
||||
});
|
||||
it("differs for two clients on the same platform", () => {
|
||||
const a = generateDeviceFingerprint(
|
||||
{
|
||||
type: "desktop",
|
||||
browser: "Termix Desktop",
|
||||
version: "2.7.0",
|
||||
os: "Linux",
|
||||
deviceInfo: "",
|
||||
},
|
||||
"a".repeat(64),
|
||||
);
|
||||
const b = generateDeviceFingerprint(
|
||||
{
|
||||
type: "desktop",
|
||||
browser: "Termix Desktop",
|
||||
version: "2.7.0",
|
||||
os: "Linux",
|
||||
deviceInfo: "",
|
||||
},
|
||||
"b".repeat(64),
|
||||
);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("produces a 64-char hex sha256 digest", () => {
|
||||
const fp = generateDeviceFingerprint({
|
||||
type: "desktop",
|
||||
browser: "Termix Desktop",
|
||||
version: "2.3.1",
|
||||
os: "macOS",
|
||||
deviceInfo: "",
|
||||
});
|
||||
expect(fp).toMatch(/^[0-9a-f]{64}$/);
|
||||
it("does not trust clients without a device id", () => {
|
||||
const fp = generateDeviceFingerprint(
|
||||
{
|
||||
type: "desktop",
|
||||
browser: "Termix Desktop",
|
||||
version: "2.3.1",
|
||||
os: "macOS",
|
||||
deviceInfo: "",
|
||||
},
|
||||
null,
|
||||
);
|
||||
expect(fp).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDeviceId", () => {
|
||||
it("accepts a 256-bit hex device id", () => {
|
||||
const deviceId = "a".repeat(64);
|
||||
expect(getDeviceId(reqWith({ "x-termix-device-id": deviceId }))).toBe(
|
||||
deviceId,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing or malformed device ids", () => {
|
||||
expect(getDeviceId(reqWith({}))).toBeNull();
|
||||
expect(
|
||||
getDeviceId(reqWith({ "x-termix-device-id": "shared-linux" })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user