mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +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:
837 files changed
+387941
-15831
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user