mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
release-2.6.1 (#1161)
* fix: preserve remote sync references (#1092) * fix: centralize outbound address validation (#1093) * fix: preserve architecture in unpacked ASAR path (#1094) * fix: allow sharing empty folders (#1096) * fix: preserve WoL broadcast address (#1097) * fix: deduplicate shared hosts (#1098) * fix snippet execution result handling (#1099) * fix SSH login alert delivery (#1100) * fix outbound DNS lookup callback shape (#1101) * fix OIDC verification for JWKs without alg (#1102) * fix file manager navigation after permission errors (#1103) * fix database persistence during container shutdown (#1104) * fix: persist host command history setting (#1107) * fix: recognize Windows terminal Tab events (#1109) * fix: recognize Windows terminal Tab events * style: format terminal key event test * fix: export repository user record (#1111) * fix: keep localhost database export same-origin (#1112) * fix: support Tailscale auth in tmux monitor (#1113) * fix: forward Android hardware keyboard keys (#1114) * fix: expose jump tunnels to guacd (#1115) * fix OIDC login with unverifiable ID tokens (#1117) verifyOIDCToken passed the raw id_token straight to jose's jwtVerify, which throws JWSInvalid when the token is not a three-segment compact JWS. Authentik issues an encrypted JWE id_token when the provider has an encryption key set, so the callback threw and every OIDC login failed with 'Invalid Compact JWS'. 2.5.0 hid this behind a catch-all that decoded the unverified payload; removing that fallback fixed the trust bug but turned the pre-existing verification failure into a hard login failure. Check the segment count before verifying and raise a distinct OIDCTokenFormatError, which the callback treats as 'no usable claims here' and falls through to the userinfo endpoint. Signature and claim failures still reject the login. Fixes Termix-SSH/Support#1016 Fixes Termix-SSH/Support#1018 * refuse to start with an empty database when data exists elsewhere (#1118) When the data directory holds no database, startup treats it as a first run and silently creates an empty one. A deployment that loses DATA_DIR — an .env file the service no longer loads, a volume that did not mount — lands in exactly that state, so the user is asked to register an admin account again while the real database sits untouched one directory over. It is indistinguishable from the upgrade having deleted everything. Check the known data locations before creating a new database and refuse to start when one of them already holds a database, naming both directories. ALLOW_EMPTY_DATA_DIR=true starts anyway for anyone deliberately starting over. This matches how a failed decryption already behaves: it throws rather than falling back to an empty database. Closes Termix-SSH/Support#1006 * stop read-only shared hosts from being dragged into folders (#1119) Shared hosts hide their edit, share and delete actions based on the recipient's permission level, but the sidebar row stays draggable regardless. Dropping one on a folder issues a bulk folder update the server rejects, so a recipient without edit rights gets a failure toast for an action the UI offered them. Gate draggable on canEditHost, and skip hosts the recipient cannot edit in the move handler so a mixed selection moves what it can instead of failing whole. Closes Termix-SSH/Support#1011 * apply the configured RDP resolution to the session (#1120) The host editor stores width and height in guacamoleConfig, and the backend passes them to guacd in the connection token. The renderer then appends its own width and height query parameters measured from the container, which take precedence, so a configured resolution never reached the session — only dpi did, because that was the one display field GuacamoleApp read back. Pass the configured width and height alongside dpi, and skip the container-driven sendSize on connect and on resize when a resolution is pinned. rescaleDisplay still fits the fixed display into the available space. Closes Termix-SSH/Support#1039 * honour per-host recording flags and explain a missing recording (#1121) The session recording section offers a recording path, a filename template and four content toggles, but the backend overwrote five of the six on every connection. A host could set none of them and get no indication why. Location and filename genuinely are not the host's to choose — recordings are indexed by them for playback and the backend refuses to read outside its recordings directory — so drop those two inputs rather than keep pretending they apply. The content flags are a host-level decision, so default them instead of forcing them. That still leaves the reported case, where guacd writes the file somewhere the backend cannot see it. The warning now reports both paths and names the two env vars that align them, which is otherwise guesswork for a split-container setup. Closes Termix-SSH/Support#1041 * route desktop guacd calls to the connected remote server (#1122) resolveConnectionOrigin() pins RDP/VNC/Telnet to "remote" because the embedded desktop backend does not bundle guacd, and the Guacamole websocket already follows that. The status check and both token calls did not: they use the shared authApi, which in Electron is hard-coded to the embedded backend. So the desktop app asked the backend without guacd whether guacd was available, got "disconnected", and refused to connect — while the connected server it would actually have used reports it as connected and serves the same host fine from the web client. Send those three calls through a remote-origin instance in Electron, alongside the existing file-manager, tunnel and stats ones. Closes Termix-SSH/Support#1043 * move the Homebrew cask to where a tap looks for it (#1123) A tap discovers casks in a top-level Casks/ directory. The cask sat in packaging/Casks/, so tapping the repository succeeded and every subsequent brew install --cask termix reported that no cask with that name exists. Move it and repoint the five workflow references. The release job still rewrites the version and checksum in place, and the electron job still copies it into the generated and submission trees. Closes Termix-SSH/Support#1044 * stop highlighting inside a split control string (#1124) A control string (OSC/DCS/APC/PM) carries text that must never be displayed — an OSC 0 title holds the user, host and path, and PROMPT_COMMAND emits one on every prompt. Its opener and its terminator routinely land in different websocket frames, and the continuation frame contains no escape byte at all, so every guard in the highlighter misses it: TUI_SEQUENCE, CONTROL_STRING_SEQUENCE and hasIncompleteAnsiSequence all only look at one chunk. Highlighting that continuation injects an SGR sequence into the middle of the open string, which aborts it early in xterm.js and prints the remainder as ordinary text — the stray ~/path glued to the prompt, and the cursor arithmetic drift behind the duplicate prompts and Ctrl+R corruption. Track the state across chunks the way alternate-screen mode already is, and skip any chunk that starts or ends inside a control string. A trailing lone ESC counts as inside, since its meaning only arrives with the next chunk. Closes Termix-SSH/Support#1025 * stop session-log route test importing the real repository layer (#1125) The test mocks db, logger and AuthManager, but the route module also calls PermissionManager.getInstance() at import time and pulls in the repository factory, which loads the drizzle schema and the better-sqlite3 native binding. Importing that costs seconds when the full suite runs its projects concurrently, and the test times out at 5s. On its own it passes, so it read as flaky rather than as a missing mock. Mock both. None of it is under test here, and the file now imports in milliseconds regardless of load. * fail the guacamole-lite patch when an anchor is gone (#1126) Each patch bails out with a console.log and process.exit(0) when its anchor string is missing. The write-back happens at the end of the file, so an upstream release that moves any one anchor drops every patch, exits successfully, and leaves postinstall reporting nothing wrong. Termix then builds and starts normally and drops VNC/RDP sessions at runtime — with no signal pointing at the patch. Every patch here is required for correctness: protocol negotiation, the guacd 1.6.0 name handshake, dynamic argument answering, UTF-8 tokens, read-only joins. A missing anchor means the patch no longer applies, so exit non-zero and say which one and what to do. Unchanged: a missing guacamole-lite still skips quietly, and an already-patched tree still exits 0. * fix: clarify desktop local profile (#1095) * fix: clarify desktop local profile * cover the AccordionSection hidden branch The desktop build hides the Security section because the embedded profile signs in automatically and has no login password, so the controls there would imply a protection that does not exist. Nothing asserted that hidden actually keeps the children out of the DOM rather than merely collapsing them. Export the component and cover both states, including that an expanded hidden section still renders nothing. * fix: show remote sync account identity (#1110) * fix: show remote sync account identity * cover getRemoteSyncUserInfo and make its null contract hold Nothing asserted the renderer-side gate: browser builds must not reach for the IPC bridge, and a missing bridge, an unconfigured server, an expired JWT or a failed channel all have to degrade to no identity rather than throw. Writing that turned up a mismatch — with no preload bridge the optional chain resolved to undefined while the signature promises null. The only caller uses ??, so nothing is broken today, but the type was not telling the truth. The main-process half (token expiry, /users/me, the roles fallback) stays uncovered: remote-sync.cjs requires electron at load, so exercising it means stubbing safeStorage and the filesystem, which is a bigger change than this PR warrants. * improve settings navigation and legal disclosure (#1105) * fix desktop preference synchronization (#1106) * fix: use jump host SOCKS proxy settings (#1116) * ci(deps): bump the github-actions group with 2 updates (#1086) Bumps the github-actions group with 2 updates: [actions/setup-node](https://github.com/actions/setup-node) and [useblacksmith/setup-docker-builder](https://github.com/useblacksmith/setup-docker-builder). Updates `actions/setup-node` from 6 to 7 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) Updates `useblacksmith/setup-docker-builder` from 1 to 2 - [Release notes](https://github.com/useblacksmith/setup-docker-builder/releases) - [Commits](https://github.com/useblacksmith/setup-docker-builder/compare/v1...v2) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: useblacksmith/setup-docker-builder dependency-version: '2' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 23 updates (#1087) Bumps the dev-patch-updates group with 23 updates: | Package | From | To | | --- | --- | --- | | [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.4` | `2.5.5` | | [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.17` | `1.2.20` | | [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.20` | `1.1.23` | | [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.8` | `1.3.11` | | [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.20` | `1.1.23` | | [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.21` | `2.1.24` | | [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.12` | `2.1.15` | | [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.20` | `1.1.23` | | [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.13` | `1.1.16` | | [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.15` | `1.2.18` | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.4` | `2.3.7` | | [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.12` | `1.1.15` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.4` | `1.4.7` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.3.0` | `1.3.3` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.4` | `1.3.7` | | [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.18` | `1.1.21` | | [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.13` | `1.2.16` | | [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.3` | `6.0.4` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `10.0.3` | `10.0.4` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.3` | `1.6.7` | | [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.7` | `19.2.8` | | [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.7` | `19.2.8` | | [react-i18next](https://github.com/i18next/react-i18next) | `17.0.10` | `17.0.11` | Updates `@biomejs/biome` from 2.5.4 to 2.5.5 - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.5/packages/@biomejs/biome) Updates `@radix-ui/react-accordion` from 1.2.17 to 1.2.20 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion) Updates `@radix-ui/react-alert-dialog` from 1.1.20 to 1.1.23 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog) Updates `@radix-ui/react-checkbox` from 1.3.8 to 1.3.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox) Updates `@radix-ui/react-dialog` from 1.1.20 to 1.1.23 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog) Updates `@radix-ui/react-dropdown-menu` from 2.1.21 to 2.1.24 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu) Updates `@radix-ui/react-label` from 2.1.12 to 2.1.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label) Updates `@radix-ui/react-popover` from 1.1.20 to 1.1.23 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover) Updates `@radix-ui/react-progress` from 1.1.13 to 1.1.16 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress) Updates `@radix-ui/react-scroll-area` from 1.2.15 to 1.2.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area) Updates `@radix-ui/react-select` from 2.3.4 to 2.3.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-separator` from 1.1.12 to 1.1.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator) Updates `@radix-ui/react-slider` from 1.4.4 to 1.4.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-slot` from 1.3.0 to 1.3.3 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-switch` from 1.3.4 to 1.3.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `@radix-ui/react-tabs` from 1.1.18 to 1.1.21 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs) Updates `@radix-ui/react-tooltip` from 1.2.13 to 1.2.16 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip) Updates `@vitejs/plugin-react` from 6.0.3 to 6.0.4 - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.4/packages/plugin-react) Updates `concurrently` from 10.0.3 to 10.0.4 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v10.0.3...v10.0.4) Updates `radix-ui` from 1.6.3 to 1.6.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/1.6.7/packages/react/radix-ui) Updates `react` from 19.2.7 to 19.2.8 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.2.8/packages/react) Updates `react-dom` from 19.2.7 to 19.2.8 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom) Updates `react-i18next` from 17.0.10 to 17.0.11 - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v17.0.10...v17.0.11) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-version: 2.5.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-accordion" dependency-version: 1.2.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-alert-dialog" dependency-version: 1.1.23 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-checkbox" dependency-version: 1.3.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dialog" dependency-version: 1.1.23 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dropdown-menu" dependency-version: 2.1.24 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-label" dependency-version: 2.1.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-popover" dependency-version: 1.1.23 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-progress" dependency-version: 1.1.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-scroll-area" dependency-version: 1.2.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-separator" dependency-version: 1.1.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-slot" dependency-version: 1.3.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tabs" dependency-version: 1.1.21 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tooltip" dependency-version: 1.2.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitejs/plugin-react" dependency-version: 6.0.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: concurrently dependency-version: 10.0.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: radix-ui dependency-version: 1.6.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: react dependency-version: 19.2.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: react-dom dependency-version: 19.2.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: react-i18next dependency-version: 17.0.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-patch-updates group with 3 updates (#1088) Bumps the prod-patch-updates group with 3 updates: [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual), [jose](https://github.com/panva/jose) and [js-yaml](https://github.com/nodeca/js-yaml). Updates `@tanstack/react-virtual` from 3.14.6 to 3.14.8 - [Release notes](https://github.com/TanStack/virtual/releases) - [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md) - [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.8/packages/react-virtual) Updates `jose` from 6.2.3 to 6.2.4 - [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.3...v6.2.4) Updates `js-yaml` from 5.2.1 to 5.2.2 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.2.1...5.2.2) --- updated-dependencies: - dependency-name: "@tanstack/react-virtual" dependency-version: 3.14.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: jose dependency-version: 6.2.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: js-yaml dependency-version: 5.2.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump undici in the prod-minor-updates group (#1089) Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.7.0 to 8.9.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.7.0...v8.9.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.9.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): bump the major-updates group with 4 updates (#1090) Bumps the major-updates group with 4 updates: [better-sqlite3](https://github.com/WiseLibs/better-sqlite3), [chalk](https://github.com/chalk/chalk), [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) and [typescript](https://github.com/microsoft/TypeScript). Updates `better-sqlite3` from 12.11.1 to 13.0.1 - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.11.1...v13.0.1) Updates `chalk` from 5.6.2 to 6.0.0 - [Release notes](https://github.com/chalk/chalk/releases) - [Commits](https://github.com/chalk/chalk/compare/v5.6.2...v6.0.0) Updates `@testing-library/jest-dom` from 6.9.1 to 7.0.0 - [Release notes](https://github.com/testing-library/jest-dom/releases) - [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.0) Updates `typescript` from 6.0.3 to 7.0.2 - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/commits) --- updated-dependencies: - dependency-name: better-sqlite3 dependency-version: 13.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: chalk dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@testing-library/jest-dom" dependency-version: 7.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: typescript dependency-version: 7.0.2 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> * restore lint by pinning typescript below 7 (#1131) #1090 bumped typescript to 7.0.2. typescript-eslint declares `typescript: >=4.8.4 <6.1.0`, and TypeScript 7 removed `ts.Extension`, which @typescript-eslint/typescript-estree dereferences at import time: node_modules/@typescript-eslint/typescript-estree/dist/create-program/shared.js:59 ts.Extension.Cjs, TypeError: Cannot read properties of undefined (reading 'Cjs') ESLint hits that while loading eslint.config.mjs, so `npm run lint` fails before linting anything. Node reports it as ERR_INTERNAL_ASSERTION, which hides the cause. Every open PR fails this check, not just new ones. Even the latest typescript-eslint prerelease still caps at <6.1.0, so there is nothing to upgrade to yet. Pin back to ~6.0.3 and tell dependabot to hold major typescript bumps until the ecosystem catches up. Also fixes biome.json pointing vcs.defaultBranch at dev-2.5.0, a branch that no longer exists. * make the repository layer engine-agnostic (#1127) DatabaseContext handed every repository a raw better-sqlite3 handle alongside drizzle, and three of them used it for retention queries built on datetime('now', ?) — a SQLite-only function. That handle is the one thing standing between the repository layer and a second engine. Drop it. The two time-based prunes compute their cutoff in JS against the CURRENT_TIMESTAMP text format, which every engine writes the same way and which compares correctly as a string; the health-history prune becomes a select of the rows to keep followed by a NOT IN delete. All three turn async, so their two callers await them. Name the dialect rather than repeating a string literal, so adding an engine is one edit instead of a search. Tests built their schema through context.sqlite?.exec(). Optional chaining meant removing the field type-checked cleanly and then silently created no tables, so the fixture now owns exec() and a raw handle for direct assertions — schema setup belongs to the test harness, not to the interface repositories consume. No behaviour change, and no Postgres yet: this only removes the coupling that would have to be undone first. * keep audit trails and recordings when a user is deleted (#1128) audit_logs and session_recordings both referenced users with ON DELETE CASCADE, so removing an account erased everything it had ever done. An audit trail that disappears with the account it recorded cannot answer the question it exists for, and a recording is evidence about a host as much as about a person. Both foreign keys become ON DELETE SET NULL. audit_logs already denormalises username, so an entry still names who acted once the reference is gone. session_recordings did not, so the column is added and backfilled first — otherwise relaxing the constraint would only trade deleted evidence for anonymous evidence. SQLite cannot alter a foreign key in place, so existing databases are migrated by copy-and-swap, guarded by a PRAGMA check that makes it idempotent. Fresh databases are created in the target shape and skip it. Recordings still cascade from their host. * audit the remaining remote access paths (#1129) Only SSH terminal sessions were audited. Opening a file manager session, an RDP, VNC or Telnet desktop, a Docker session or an SSH tunnel left no audit entry at all — which covers most of the ways data leaves a host or a foothold is established. Each of those four now writes an entry when the session is established, matching the existing ssh_connect: who, which host, from what address, and for tunnels the endpoint and local port being forwarded. Audit writes are fire-and-forget so they cannot delay or fail the connection, consistent with logAudit already swallowing its own errors. getAuditUsername was defined identically in two route files and is needed in four more, so it moves next to logAudit. * fix: honor lookupOptions.all in custom DNS lookup hook (#1084) Node's happy-eyeballs autoSelectFamily calls custom dns lookup functions with all:true and expects the full address array back. Always replying with a single (address, family) pair corrupted net's internal state, surfacing as "Invalid IP address: undefined" instead of a real connect error, breaking outbound notification delivery (webhook/ntfy). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: SSH-login alerts silently dropped (channel load + auth middleware ordering) (#1083) * fix: load notification channels on mount in AlertsPanel Channels only loaded when the Channels tab was visited, so opening Edit Alert Rule before ever switching to that tab showed the channel picker as empty even when channels existed. (cherry picked from commit caed913ee91990a853f5a048849c67ed3f7c329e) * fix: register login-alert route before auth middleware Global JWT auth middleware ran before this internal service-to-service route, rejecting it with 401 before its own IP+token check ever ran — silently dropped every SSH-login alert. Also surface non-OK responses instead of swallowing them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: add coverage for alert-notification fixes Channel-load-on-mount, login-alert non-OK handling, and a source-order guard for the route/auth-middleware regression. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * format AlertsPanel test with prettier --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * stop deleting audit trails, and say when they are dropped (#1132) Two ways audit evidence still disappeared silently. Deleting an account removed its audit entries and session recordings outright. #1128 relaxed those foreign keys to ON DELETE SET NULL, but deleteUserAndRelatedData deletes the rows explicitly, so the schema change had no effect on the path that actually matters. Both repositories gain anonymizeByUserId, which nulls the reference and leaves the row; username is already denormalised on both tables, so entries stay attributable to whoever acted. Separately, the log pruned itself at a hard-coded 10000 rows with no signal. Entries well inside any retention window were discarded and nothing recorded it. Retention is now configurable by age via AUDIT_LOG_RETENTION_DAYS, the row cap via AUDIT_LOG_MAX_ENTRIES, and the two are reported differently: expiring an old entry is routine and logged at info, while hitting the cap means the ceiling is too low for how much this install audits and is logged at warn, naming the range discarded and how to stop it. * let the audit log leave the box (#1133) Retention became configurable in #1132, which only helps if entries can be moved somewhere before they expire. Until now the only way out was two GET endpoints built for the UI. Adds GET /audit-logs/export, taking the same filters as the list endpoint and streaming the whole matching set as CSV or NDJSON in batches, so an export is not bounded by the 200-row page cap and does not buffer the result set. Reading the entire trail is itself recorded as export_audit_logs. CSV fields starting with =, +, - or @ are prefixed with a quote. Audit rows carry attacker-influenced values like resource names, and spreadsheet software treats those as formulas on open. Adds optional live forwarding to a collector via AUDIT_LOG_FORWARD_URL, with an optional bearer token. Delivery goes through safeOutboundFetch so a misconfigured URL cannot be turned into an internal network probe, and it is fire-and-forget: the local write stays the source of truth and a dead SIEM must never delay or fail the operation being audited. Repeated failures are reported five times and then suppressed until delivery recovers, so an outage does not bury the logs it is supposed to appear in. * encrypt SSO secrets instead of base64-encoding them (#1135) The OIDC client secret and LDAP bind password were stored behind an encoded: prefix that is base64, not encryption. Anyone reading the database read the secrets. A second path wrote the same thing behind an encrypted: prefix, which was also base64 — and the reader even documented that it could not decrypt it. These belong to the installation rather than to a user: sso_providers has no userId, and the values must be readable during login, before anyone has authenticated, so the per-user DEK used elsewhere does not apply. They are now sealed with AES-256-GCM under the system encryption key, which already protects other installation-level material. Reading handles both legacy prefixes so an existing install is not locked out of SSO login, and a legacy value is upgraded the next time the provider is saved. The three scattered encode/decode sites are replaced by one module. * remove the unwired field encryption boundary (#1136) FieldEncryptionBoundary declared a full sensitive/plaintext policy for six tables and was referenced only by its own test. Nothing in production used it. Its policy is byte-for-byte the same as FieldCrypto.ENCRYPTED_FIELDS, which is the copy that actually runs, so nothing is lost by deleting it. Keeping a second list is the real risk: someone adds a field to this one, sees it classified as sensitive, and ships something that was never encrypted. The one apparent improvement it had — requiring an explicit recordId instead of DataCrypto's temp-${Date.now()} fallback — turns out to guard against nothing. decryptField derives its context from the recordId stored inside the ciphertext, not from the argument, so a temporary id at encryption time still decrypts. * load the database file when encryption is off (#1137) * Groundwork for Postgres and MySQL backends (#1134) * groundwork for postgres and mysql backends #1127 made the repository layer dialect-agnostic. This adds the pieces needed to actually target a second engine, as a foundation only — nothing is wired up and sqlite remains the sole runtime path. - DatabaseDialect covers sqlite, postgres and mysql, resolved from DATABASE_DIALECT and defaulting to sqlite so nothing changes for existing deployments or the desktop build - a column kit holding the per-dialect type choices in one file: booleans are integers on sqlite and native elsewhere, autoincrement differs three ways, and MySQL cannot index unbounded TEXT so key columns need varchar - settings and users declared for all three dialects as a proof slice, chosen because between them they use every construct the real schema does - pg and mysql2 added as dependencies The tests build real queries for all three engines without a server, asserting identifier quoting, placeholder style and boolean storage, so the property the repositories depend on is verified rather than assumed. * verify foreign keys and unique constraints port across dialects The first slice only covered plain columns. The real schema also has 92 foreign keys (80 cascade, 12 set null) and 14 unique columns, so the approach is only viable if those survive the port. Adds audit_logs and ssh_folders to the proof slice: one nullable reference with ON DELETE SET NULL, one required reference with ON DELETE CASCADE, a unique column, and an autoincrement surrogate key — which is spelled three different ways underneath (integer primary key autoincrement, serial, int auto_increment). All of it holds. Worth noting for whoever picks this up: getTableConfig is dialect-specific and silently fails on a table from another dialect, so the test uses each engine's own. * generate the postgres and mysql schemas instead of hand-writing them The proof slice showed the constructs port, but left the maintenance question open. Three hand-written copies of 52 tables is the wrong answer: with foreign keys the copies cross-reference each other, so a renamed table has to land in three places consistently or a key silently points at the wrong one. The mapping is mechanical, so a script does it. schema.ts stays the single source of truth and schema.pg.ts / schema.mysql.ts are derived, covering all 52 tables — the column kit and the two-table portable slice are gone, since the generator now holds those decisions. The transforms are the ones the kit enumerated: integer-backed booleans become native, autoincrement keys become serial or int auto_increment, real becomes double precision or double, and any column that is a primary key, is unique, or sits on either end of a foreign key becomes varchar because MySQL cannot index unbounded TEXT. > termix@2.6.0 lint > node scripts/generate-dialect-schema.cjs --check && eslint . /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-favicon-routes.ts 99:12 warning 'err' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-ping-routes.ts 123:12 warning 'err' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-rss-routes.ts 144:12 warning 'err' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/session-log-routes.ts 46:16 warning 'canAccessRecording' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/hosts/vault-signer-core.ts 55:12 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any 75:13 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/tests/hosts/auth-manager.test.ts 18:73 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/tests/utils/shared-host-secrets-manager.test.ts 7:6 warning 'SecretRow' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/utils/auth-manager.ts 510:13 warning 'affectedUsers' is assigned a value but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/utils/notification-sender.ts 48:12 warning 'firstErr' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/api/ssh-file-operations-api.ts 35:10 warning 'buildFileManagerUrl' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/components/folder-style.tsx 61:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 116:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 121:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 149:17 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx 109:19 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any 190:19 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/HomepageCanvas.tsx 345:15 warning Empty block statement no-empty 388:15 warning Empty block statement no-empty 415:15 warning Empty block statement no-empty /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/dialogs/SingleHostEditForm.tsx 24:6 warning React Hook useEffect has a missing dependency: 'filter'. Either include it or remove the dependency array. If 'setHosts' needs the current value of 'filter', you can also switch to useReducer instead of useState and read 'filter' in the reducer react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/AlertFeedWidget.tsx 93:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/CustomApiWidget.tsx 77:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/DockerActivityWidget.tsx 50:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/DockerWidget.tsx 16:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/FileManagerWidget.tsx 16:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/HostGridWidget.tsx 61:6 warning React Hook useCallback has a missing dependency: 'hostIds'. Either include it or remove the dependency array react-hooks/exhaustive-deps 61:7 warning React Hook useCallback has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/MetricsChartWidget.tsx 168:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/PingStatusWidget.tsx 79:6 warning React Hook useEffect has a missing dependency: 'fetchAll'. Either include it or remove the dependency array react-hooks/exhaustive-deps 79:7 warning React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/QuickConnectWidget.tsx 64:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/RecentActivityWidget.tsx 82:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps 82:17 warning React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SshQuickConnectWidget.tsx 67:6 warning React Hook useCallback has a missing dependency: 'hostIds'. Either include it or remove the dependency array react-hooks/exhaustive-deps 67:7 warning React Hook useCallback has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps 99:17 warning 'online' is assigned a value but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SshTerminalWidget.tsx 17:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SystemOverviewWidget.tsx 72:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/TunnelWidget.tsx 15:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/host-metrics/cards/CpuCard.tsx 14:10 warning 'computeChartData' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/sidebar/FolderPathPicker.tsx 15:17 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 22:17 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/sidebar/HostsPanel.tsx 601:52 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any ✖ 44 problems (0 errors, 44 warnings) now fails if the generated files are out of date, so editing the schema without regenerating cannot reach main. * select durability behaviour per dialect, and document the backends The onWrite hook every repository receives exists to serialise the in-memory SQLite database back to its encrypted file. On a client-server engine a committed write is already durable and there is nothing to flush, so the factory now installs no hook at all rather than one that does nothing. Repositories call it as this.onWrite?.(), so none of the 43 of them change. Also adds docs/database-backends.md, mostly to be explicit about encryption, which is the part most likely to be misread. Field-level encryption is identical on all three engines and covers every credential. Whole-file encryption has no equivalent on Postgres or MySQL, so host names, snippet contents, audit entries and backups are only as protected as the storage underneath them — that is the operator's responsibility and the docs should not imply otherwise. * generate DDL with drizzle-kit, and give settings a synchronous path Two of the three remaining blockers. DDL: db/index.ts hand-writes 67 CREATE TABLE statements and 122 ADD COLUMN migrations, all in SQLite dialect. Rather than port them, drizzle-kit now generates migrations from the schema modules — 817 lines for Postgres, 869 for MySQL, with the type mapping already correct because the schemas it reads are themselves generated. > termix@2.6.0 schema:migrations > drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts Reading config file '/mnt/c/Users/29037/WebstormProjects/Termix/drizzle.config.pg.ts' 52 tables alert_firings 11 columns 0 indexes 2 fks alert_rule_channels 3 columns 0 indexes 2 fks alert_rules 11 columns 0 indexes 2 fks api_keys 9 columns 0 indexes 1 fks audit_logs 13 columns 0 indexes 1 fks c2s_tunnel_presets 8 columns 0 indexes 1 fks command_history 5 columns 0 indexes 2 fks dashboard_service_links 8 columns 0 indexes 1 fks dismissed_alerts 4 columns 0 indexes 1 fks file_manager_pinned 6 columns 0 indexes 2 fks file_manager_recent 6 columns 0 indexes 2 fks file_manager_shortcuts 6 columns 0 indexes 2 fks homepage_items 9 columns 0 indexes 1 fks homepage_layouts 4 columns 0 indexes 1 fks host_access 11 columns 0 indexes 5 fks host_health_checks 7 columns 0 indexes 2 fks host_health_history 8 columns 0 indexes 2 fks host_metrics_history 8 columns 0 indexes 1 fks host_metrics_preferences 6 columns 0 indexes 2 fks ssh_data 94 columns 0 indexes 6 fks network_topology 5 columns 0 indexes 1 fks notification_channels 7 columns 0 indexes 1 fks opkssh_tokens 12 columns 0 indexes 2 fks recent_activity 6 columns 0 indexes 2 fks roles 8 columns 0 indexes 0 fks session_recordings 15 columns 0 indexes 3 fks session_share_participants 6 columns 0 indexes 2 fks session_shares 15 columns 0 indexes 3 fks sessions 11 columns 0 indexes 1 fks settings 2 columns 0 indexes 0 fks shared_host_secrets 15 columns 0 indexes 3 fks snippet_access 8 columns 0 indexes 4 fks snippet_folders 8 columns 0 indexes 1 fks snippets 11 columns 0 indexes 1 fks ssh_credential_usage 5 columns 0 indexes 3 fks ssh_credentials 21 columns 0 indexes 1 fks ssh_folders 9 columns 0 indexes 2 fks sso_providers 8 columns 0 indexes 0 fks sync_tombstones 5 columns 0 indexes 1 fks termix_identities 6 columns 0 indexes 1 fks termix_identity_ca 8 columns 0 indexes 2 fks termix_identity_keys 12 columns 0 indexes 3 fks tmux_session_tags 6 columns 0 indexes 2 fks transfer_recent 7 columns 0 indexes 3 fks trusted_devices 8 columns 0 indexes 1 fks user_open_tabs 9 columns 0 indexes 2 fks user_preferences 23 columns 0 indexes 1 fks user_roles 5 columns 0 indexes 3 fks users 20 columns 0 indexes 0 fks vault_profiles 18 columns 0 indexes 1 fks vault_tokens 8 columns 0 indexes 2 fks webauthn_credentials 12 columns 0 indexes 1 fks No schema changes, nothing to migrate 😴 Reading config file '/mnt/c/Users/29037/WebstormProjects/Termix/drizzle.config.mysql.ts' Reading schema files: /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/db/schema.mysql.ts 52 tables alert_firings 11 columns 0 indexes 2 fks alert_rule_channels 3 columns 0 indexes 2 fks alert_rules 11 columns 0 indexes 2 fks api_keys 9 columns 0 indexes 1 fks audit_logs 13 columns 0 indexes 1 fks c2s_tunnel_presets 8 columns 0 indexes 1 fks command_history 5 columns 0 indexes 2 fks dashboard_service_links 8 columns 0 indexes 1 fks dismissed_alerts 4 columns 0 indexes 1 fks file_manager_pinned 6 columns 0 indexes 2 fks file_manager_recent 6 columns 0 indexes 2 fks file_manager_shortcuts 6 columns 0 indexes 2 fks homepage_items 9 columns 0 indexes 1 fks homepage_layouts 4 columns 0 indexes 1 fks host_access 11 columns 0 indexes 5 fks host_health_checks 7 columns 0 indexes 2 fks host_health_history 8 columns 0 indexes 2 fks host_metrics_history 8 columns 0 indexes 1 fks host_metrics_preferences 6 columns 0 indexes 2 fks ssh_data 94 columns 0 indexes 6 fks network_topology 5 columns 0 indexes 1 fks notification_channels 7 columns 0 indexes 1 fks opkssh_tokens 12 columns 0 indexes 2 fks recent_activity 6 columns 0 indexes 2 fks roles 8 columns 0 indexes 0 fks session_recordings 15 columns 0 indexes 3 fks session_share_participants 6 columns 0 indexes 2 fks session_shares 15 columns 0 indexes 3 fks sessions 11 columns 0 indexes 1 fks settings 2 columns 0 indexes 0 fks shared_host_secrets 15 columns 0 indexes 3 fks snippet_access 8 columns 0 indexes 4 fks snippet_folders 8 columns 0 indexes 1 fks snippets 11 columns 0 indexes 1 fks ssh_credential_usage 5 columns 0 indexes 3 fks ssh_credentials 21 columns 0 indexes 1 fks ssh_folders 9 columns 0 indexes 2 fks sso_providers 8 columns 0 indexes 0 fks sync_tombstones 5 columns 0 indexes 1 fks termix_identities 6 columns 0 indexes 1 fks termix_identity_ca 8 columns 0 indexes 2 fks termix_identity_keys 12 columns 0 indexes 3 fks tmux_session_tags 6 columns 0 indexes 2 fks transfer_recent 7 columns 0 indexes 3 fks trusted_devices 8 columns 0 indexes 1 fks user_open_tabs 9 columns 0 indexes 2 fks user_preferences 23 columns 0 indexes 1 fks user_roles 5 columns 0 indexes 3 fks users 20 columns 0 indexes 0 fks vault_profiles 18 columns 0 indexes 1 fks vault_tokens 8 columns 0 indexes 2 fks webauthn_credentials 12 columns 0 indexes 1 fks No schema changes, nothing to migrate 😴 regenerates both. Settings: 27 call sites read settings synchronously, during startup and inside request handlers. better-sqlite3 can do that; Postgres and MySQL cannot, and making all 27 async would push await through code that has no reason to be asynchronous. Settings are a handful of rarely-changing rows read constantly, so they are cached in full — primed at startup, kept in step by SettingsRepository on every set/delete/deleteLike. SQLite keeps reading the database directly and stays authoritative; only the other engines use the cache. Opening a connection is still not done. DatabaseContext.drizzle is typed as BetterSQLite3Database and 43 repositories depend on that inference; the three drizzle instance types are not interchangeable, so widening it is a design decision rather than a mechanical change. * exclude drizzle-kit output from prettier The generated migrations and snapshots are tool output; their formatting is drizzle-kit's to decide, and prettier cannot parse the .sql files at all. * absorb the RETURNING gap so mysql stays reachable MySQL has no RETURNING clause and drizzle's mysql-core does not expose the method, while 156 call sites here read the result of a write. That is the real blocker for MySQL, not the connection layer. Classifying those call sites showed the split is favourable: 92 of them only read .length, which every engine reports — as a returned array on sqlite and postgres, as affectedRows on MySQL. rowsAffected() reads both shapes, so those sites need no change in query shape. insertedId() does the same for the autoincrement key, which MySQL reports as insertId. What is left is the ~34 sites that genuinely consume the returned rows. Those cannot be emulated without reading first, which needs a transaction to stay correct under concurrency, so they will be handled individually rather than behind a helper that quietly adds a round trip. supportsReturning() is the seam for that. Identifying the mysql2 result by its own fields rather than by array shape matters: it hands back [ResultSetHeader, fields], which is an array, so shape alone cannot tell it apart from a returning() result. * name the portable database type, and open remote connections Two pieces of the connection layer. drizzle's three Database classes share no base class and their signatures are incompatible, so there is no honest type that covers all three: a union is not callable and a generic would have to be threaded through 43 repositories and every method on them. DatabaseContext.drizzle is now PortableDatabase, still the SQLite type underneath, but named and documented as the deliberate approximation it is. What makes it safe is that the equivalence is asserted in multi-dialect.test.ts rather than assumed, and the one place the surfaces truly differ — RETURNING — is handled explicitly in mutation-result.ts. connect.ts opens Postgres and MySQL from DATABASE_URL, with the schema module and driver imported lazily so neither is loaded on a SQLite deployment. The URL scheme is checked against the configured dialect first: a postgres:// URL with DATABASE_DIALECT=mysql otherwise surfaces as a driver error deep in a stack that never mentions the actual misconfiguration. * open postgres and mysql at startup * count writes without RETURNING * read affected rows without RETURNING on mysql * insert without RETURNING, and split the sync transactions * stop pretending the generated schemas are used at runtime * run the dialect checks in CI * mysql rejects a bare CURRENT_TIMESTAMP default on text * make the read-back mismatch loud, and stop the next bare returning() * run the repository tests on the real schema * skip the byte-level assertions off sqlite * move generated ids past the seeded ones * keep the export order the same on every engine * stop reading better-sqlite3 fields off every write * read counts as numbers, not whatever the driver returns * make the fixture usable against a live server * upsert on the engine that has no ON CONFLICT * run the repository suite on all three engines in CI * mysql cannot index a text column without a length * document how to actually run on postgres or mysql * keep the sqlite-era migrations off the other engines * concat strings in a way mysql agrees with * run every repository test on every engine * bound how long replicas can disagree about settings * generate the sqlite migrations alongside the others * Bump version from 2.6.0 to 2.6.1 * resolve the dialect in the repository factory instead of assuming sqlite (#1143) createCurrentRepositoryContext() hardcoded `dialect: "sqlite"` while the runtime already carried all three engines. That field is not decoration: returning.ts reads it to decide whether it can ask for RETURNING, and whether an upsert spells itself onConflictDoUpdate or onDuplicateKeyUpdate. Reporting sqlite while connected to MySQL means the first upsert calls onConflictDoUpdate on a mysql2 insert builder, which does not have it -- a TypeError, not a rejected query, as the note in returning.ts warned. So MySQL never worked outside the tests, and Postgres worked only because it also supports RETURNING and shares the conflict syntax. Three things were supposed to catch this and none could. The repository suite builds its own DatabaseContext in test-support.ts, verify-dialects.mjs builds its own, and the CI matrix runs both against real Postgres and MySQL containers -- all of them bypassing the one function the application calls. Green on three engines, broken on two. Resolve it from the environment, and test the factory itself rather than a hand-built context: the default, each configured dialect, the write hook it installs only for sqlite, and that an unsupported value throws rather than falling back. Reverting the fix fails two of them. Fixes Termix-SSH/Support#282 * fix remote sync stalling after the first pass and never propagating deletions (#1140) The incremental cursor never matched. updated_at/deleted_at are TEXT columns written by CURRENT_TIMESTAMP ("2026-07-29 10:11:21"), while the client sends an ISO 8601 since ("2026-07-29T10:06:55.172Z"). Both comparisons are lexical and ' ' sorts below 'T', so a newer row lost at position 10 and every ?since= query came back empty. Pass 1 syncs everything (since is null) and persists a cursor; every pass after it returns nothing with lastError: null and reports success. Normalize since into the stored shape on the way in, leaving an already-normalized value alone -- parsing that would treat it as local time and, west of UTC, push the cursor past unsynced rows. POST /sync/tombstones was unreachable. It was registered after POST /:entityType, and "tombstones" is a valid :entityType, so the wildcard answered it with 400 "Unknown entity type" and the handler never ran. The pass has no per-entity error handling, so that 400 also discarded the state of every entity type already synced in the same pass. Move it ahead of the wildcards. The tombstone guard consulted the incremental window. A row deleted on one side and untouched on the other -- the shape every ordinary deletion takes once the two sides converge -- is not in that window, so the tombstone was skipped, and skipped again on each later pass as it slid out of its own window. The guard cannot just be dropped: recording a tombstone for a row that was already gone hands the sender a fresh one to push back, and the two trade the same deletion forever. So only a delete that removed something records a tombstone, which makes the endpoint idempotent and lets the client push every tombstone unconditionally. Deletions missed while the cursor was broken stay missed -- their tombstones predate the persisted cursor. Ordinary edits do come through, since the row's updatedAt is still newer than it. Fixes Termix-SSH/Support#1050 Fixes Termix-SSH/Support#1051 * report why every JWKS fetch failed instead of swallowing the reason (#1142) An OIDC login that cannot reach the provider's keys ends in "Failed to fetch JWKS from any URL" and nothing else. Getting there discards everything worth knowing: a non-2xx response hit an empty else branch, a thrown request hit a bare `continue`, and discovery only logged when it threw -- a 404 or a document without jwks_uri passed in silence. An administrator cannot tell an issuer URL typo from a proxy, a private CA, or an outage at the provider, and neither can anyone reading the report. Collect each attempt with its reason and put them in the thrown error. It reaches the log through the existing "OIDC callback failed" handler; the browser still gets the same generic message it did before. Unwrapping the cause is the part that matters: undici reports every transport failure as "fetch failed" and hangs the real reason -- ENOTFOUND, ECONNREFUSED, a certificate that will not verify -- off error.cause. An attempt list built from the outer messages would be as useless as the single line it replaces. Also require jwks_uri to be a string before using it, so a malformed discovery document is reported as such rather than as a failed fetch of "[object Object]". Refs Termix-SSH/Support#1047 * restore the closing quote on the version string (#1147) "Bump version from 2.6.0 to 2.6.1" (2a66775) wrote "version": "2.6.1, dropping the closing quote, so package.json has not been valid JSON since. Anything that parses it fails: npm install, npm run build, and every CI run on this branch -- vitest cannot even load its config, because vite reads package.json before it gets to the test files. 2.6.1 cannot be built or released until this is fixed, which is why it goes in on its own rather than riding along with anything else. * Revert "fix remote sync stalling after the first pass and never propagating deletions (#1140)" (#1146) This reverts commitca7abf8426. Reverted for process, not for content. Both defects were reported by @kacperpietrzyk in Support#1050 and Support#1051, and he opened #1138 and #1139 fixing them 4.5 hours before #1140 was filed. Merging #1140 made two PRs from the person who found and diagnosed the bugs redundant. #1138 and #1139 stand on their own: the same root-cause analysis, complete regression tests, and a tombstone guard that only pays for its extra lookup on a pass that actually carries a deletion. There is no technical reason to prefer the reverted commit over them. The sync fixes land through those two PRs instead. * fix: make sync deletions reach the other side (#1139) * fix: apply sync tombstones to rows outside the incremental window Deletions never reached the other side. `syncEntity` decides whether to apply a tombstone by looking the row up in `localBySyncId` / `remoteBySyncId`, which are built from `pullSide(..., since)` -- the incremental window. A row deleted on one side and untouched on the other is by definition absent from that window, which is the shape every ordinary deletion takes once the two sides have converged, so the tombstone was silently skipped and never retried. The guard cannot simply be dropped. `POST /sync/tombstones` records a tombstone on the receiving side, so an unconditional push would give the other side a fresh tombstone to push back on the following pass, and the two would trade deletions forever. Instead ask the receiving side what it still holds, ignoring the window, and only when there is a deletion to apply -- so an ordinary pass costs nothing extra, and a pass carrying a deletion costs one additional list per affected entity type. Once the row is gone the push stops, so nothing ping-pongs. Note this only becomes observable together with the cursor fix in Termix-SSH/Support#1050: while that defect is present the tombstone endpoint returns nothing at all, so there is no tombstone to apply in the first place. Refs Termix-SSH/Support#1034 * fix: make the sync tombstone endpoint reachable `POST /sync/tombstones` was registered after `POST /:entityType`, and Express matches in registration order, so every deletion push was swallowed by the wildcard: "tombstones" is a perfectly good value for :entityType, fails isValidEntityType, and comes back as 400 "Unknown entity type". The handler below it has never run. Registering the literal path before the parameterised one restores it. The regression test reads the router stack rather than the source, so a future re-order fails the test rather than silently disabling deletions again. The GET pair is unaffected -- "/:entityType/tombstones" and "/:entityType" have different segment counts, so they cannot shadow each other. * feat: add host export dialog with host and field selection (#1108) * fix: compare sync cursors independently of timestamp layout (#1138) * fix: compare sync cursors independently of timestamp layout Incremental sync returned nothing after its first pass. `GET /sync/:entityType` filters with `gt(table.updatedAt, since)` on a TEXT column, and the tombstone endpoint does the same through `listSince`, but the two sides of that comparison are written in different layouts: the columns default to `CURRENT_TIMESTAMP` ("2026-07-29 10:11:21") while the desktop engine sends `new Date().toISOString()` ("2026-07-29T10:06:55.172Z"). Text comparison is decided at position 10, where ' ' (0x20) sorts below 'T' (0x54), so the predicate answers on layout rather than on time and is false for every CURRENT_TIMESTAMP row however new it is. The engine only sends a cursor from the second pass onward, so pass 1 synced everything and passes 2..n pulled zero rows and zero tombstones while reporting success -- edits and deletions silently stopped propagating in both directions. This was masked until now: before the reference fix in #1092 the loop threw before persisting state, so the cursor never advanced past null and every cycle was a full sync. Comparing "YYYY-MM-DD HH:MM:SS" on both sides is layout-independent. `replace` and `substr` are used rather than `datetime()` to keep the expression portable across engines, since the repository layer is deliberately drizzle-only. The comparison is `>=` because normalising truncates sub-second precision, and a strict `>` would permanently skip rows written in the cursor's own second; the re-sent boundary rows are a no-op, as the engine pushes only when one side is strictly newer. `updatedAt` is written in both layouts across the codebase (14 sites use toISOString, 11 use CURRENT_TIMESTAMP), so the tests cover rows of each kind. Closes Termix-SSH/Support#1050 * test: seed the cursor tests against the migrated schema #1134 moved schema creation into the repository test harness, so the hand-written CREATE TABLE blocks here collided with tables that already existed. Seeding into the real tables instead surfaced two constraints the local definitions had papered over: the harness enables foreign keys and both `sync_tombstones.user_id` and `ssh_credentials.user_id` reference `users`, so the owning row has to be seeded first; and `auth_type` is NOT NULL with no default, unlike the local copy. `exec` is awaited, since it only returns synchronously on SQLite. The assertions are unchanged. * Make Proxmox guest discovery and import reliable over a jump host (#1144) * fix: repair unterminated version string in package.json The version field on dev-2.6.1 reads "2.6.1, (no closing quote), which makes package.json invalid JSON and breaks every npm invocation on the branch. Close the string so the branch builds. * fix(proxmox): reliable guest discovery and import over jump hosts Importing Proxmox guests from a node reachable only through a jump host (with the guests behind the same jump) failed in a chain of small ways. - Discovery timed out intermittently: execCommand capped every pvesh call at 8s, but a single call over a jump measured ~8.3s. Raised to 25s for core calls and 12s for best-effort agent/interface lookups. - No IPs were resolved (so nothing imported): resolveIp fanned out 6 concurrent pvesh calls; on a small node they contend (3 concurrent already exceeded the timeout), so every IP came back empty. Lowered CONCURRENCY to 2. - RDP guests aborted the whole sync via NOT NULL on ssh_data.username; use "" instead of null (matches the normal create path). - Guests without a resolvable IP (e.g. QEMU with no guest agent) were skipped entirely; they now import with a 0.0.0.0 placeholder, and re-sync preserves any manually entered IP (guest.ip || existing.ip). - Manual import did not inherit the source host's jump chain or credential (guests ended up unreachable with authType "none"). The discovery result now carries the source jumpHosts, and resolveProxmoxImportAuth uses an available credential even under the default "password" authType (explicit secretless choices still win). - Long discoveries had no feedback and fought client/proxy timeouts; added an SSE endpoint GET /proxmox/discover/stream (heartbeat + n/N progress), keeping POST /discover as a fallback. Also always render the IP cell in the discovery table so IP-less rows stay aligned. Adds a unit test for resolveProxmoxImportAuth covering the credential inheritance behaviour. * test(proxmox): lock resolveProxmoxImportAuth matrix on both copies; fix agent secretless drift - extract the backend decision into src/backend/database/routes/proxmox-import-auth.ts (leaf module mirroring the UI copy) so it is unit-testable without pulling the whole backend module graph into the test env - add src/backend/tests/database/routes/proxmox-import-auth.test.ts asserting the shared matrix (lifted from #1141, thanks @ZacharyZcR) - consolidate the UI test into src/ui/tests/components/proxmox/proxmox-import-auth.test.ts and drop the duplicate src/ui/tests/proxmox/ copy - add 'agent' to the UI SECRETLESS_AUTH_TYPES: the one real auth type where the two copies still diverged (UI -> credential, backend -> passthrough) * fix(hosts): parse portKnockSequence JSON in host-resolver (#1149) host-resolver JSON-parses jumpHosts/tunnelConnections/statsConfig/ terminalConfig/socks5ProxyChain/quickActions but NOT portKnockSequence. Empty knock is stored as the string "[]" (UI save of empty array); the terminal code then checks portKnockSequence.length > 0 on the STRING, so "[]".length === 2 is truthy -> logs 'Loaded 2 port knock(s)' and attempts a bogus knock. Real knock sequences (JSON string) are likewise never parsed to the Array<{port,...}> that performPortKnocking expects, so a genuine knock would never fire. Parse portKnockSequence like the other JSON columns: '[]' -> [] (length 0, no knock), real seq -> array. Adds unit tests for both cases. Co-authored-by: XtraLarge <xtralarge@users.noreply.github.com> * Feature request map OIDC provider groups to RBAC roles (#1148) * Bump version from 2.6.0 to 2.6.1 in package-lock.json * Fix formatting issue in package-lock.json * Feature request map OIDC provider groups to RBAC roles Group membership from an OIDC provider currently drives only a single boolean: OIDC_ADMIN_GROUP toggles isAdmin and switches the user between the built-in `admin` and `user` roles. There is no way to map a provider group onto a custom role, so deployments that use host_access grants for environment-scoped access (e.g. a role that can reach staging hosts and another that can reach production) have to assign those roles by hand for every user. Add OIDC_ROLE_MAP, a comma- or newline-separated list of `group:role` pairs, reconciled against the user's roles on each OIDC login: OIDC_ROLE_MAP=devops-interns:devops-intern,devops-seniors:devops-senior Only roles named in the map are ever added or removed. Roles assigned by hand, and the admin/user pair maintained by the existing admin-group sync, are deliberately left untouched so the two mechanisms don't fight each other. Group names are matched case-insensitively with leading slashes stripped, so providers that emit full group paths (Keycloak's "Full group path" option) work without extra configuration. Reuses the existing extractOidcGroups claim handling, so custom claim paths via OIDC_GROUP_CLAIM are supported too, and invalidates the permission cache when roles change so new grants apply to the session that triggered the sync. Malformed map entries are skipped and a failed sync is logged but non-fatal — neither can block a valid login. Adds unit tests for the parser and resolver covering full group paths, multi-group membership, colons in group names and malformed input. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: support for overriding shared host ssh credentials (#1145) * Bump version from 2.6.0 to 2.6.1 in package-lock.json * Fix formatting issue in package-lock.json * feat: support for overriding ssh credentials --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * ci(deps): bump the github-actions group with 2 updates (#1150) * Bump version from 2.6.0 to 2.6.1 in package-lock.json * Fix formatting issue in package-lock.json * Update README to remove Tailscale and add Ginernet Removed Tailscale logo and link from the README. Added Ginernet logo and link. * Update README.md * ci(deps): bump the github-actions group with 2 updates Bumps the github-actions group with 2 updates: [actions/setup-node](https://github.com/actions/setup-node) and [useblacksmith/setup-docker-builder](https://github.com/useblacksmith/setup-docker-builder). Updates `actions/setup-node` from 6 to 7 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) Updates `useblacksmith/setup-docker-builder` from 1 to 2 - [Release notes](https://github.com/useblacksmith/setup-docker-builder/releases) - [Commits](https://github.com/useblacksmith/setup-docker-builder/compare/v1...v2) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: useblacksmith/setup-docker-builder dependency-version: '2' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-minor-updates group with 3 updates (#1154) * Bump version from 2.6.0 to 2.6.1 in package-lock.json * Fix formatting issue in package-lock.json * Update README to remove Tailscale and add Ginernet Removed Tailscale logo and link from the README. Added Ginernet logo and link. * Update README.md * chore(deps): bump the prod-minor-updates group with 3 updates Bumps the prod-minor-updates group with 3 updates: [axios](https://github.com/axios/axios), [motion](https://github.com/motiondivision/motion) and [undici](https://github.com/nodejs/undici). Updates `axios` from 1.18.1 to 1.19.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.18.1...v1.19.0) Updates `motion` from 12.42.2 to 12.43.0 - [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md) - [Commits](https://github.com/motiondivision/motion/compare/v12.42.2...v12.43.0) Updates `undici` from 8.7.0 to 8.9.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.7.0...v8.9.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.19.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: motion dependency-version: 12.43.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.9.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: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: data guard test failure * chore(deps): bump 23 dependencies and fix dialect-unsafe queries Applies the non-major updates from the open dependabot PRs directly, since dependabot rebases against main and could not resolve its lockfiles against this branch. Holds back typescript 7 and jsdom 30; those majors need their own pass. Reformats with prettier 3.9.6, which collapses short union types onto one line. Formatting only: the compiled backend output is byte for byte identical. Also fixes two lint errors in the shared host auth override repository, where onConflictDoUpdate and .returning() are SQLite-only and broke the Postgres and MySQL builds, and drops unused imports left over from the shared host auth override merge. * chore: reversal of legal work * feat: improve pin side rail button position and added env var for telemetrics * Add Ctrl+F terminal search (#1156) * Bump version from 2.6.0 to 2.6.1 in package-lock.json * Fix formatting issue in package-lock.json * Update README to remove Tailscale and add Ginernet Removed Tailscale logo and link from the README. Added Ginernet logo and link. * Update README.md * Add Ctrl+F terminal search --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * fix: host export dialog using incorrect widths * fix: made logger display expanded errors * feat: added support for multi disk usage in file manager and host metrics * chore: harden nginx headers and improve static asset caching * chore: format * chore: update release notes * fix: default font size to md instead of lg * feat: support Tailscale SSH check mode * chore: sync Crowdin translations for 2.6.1 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@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: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Max <50905012+maxiwolleb@users.noreply.github.com> Co-authored-by: XtraLarge <eMail@WilliWerres.de> Co-authored-by: XtraLarge <xtralarge@users.noreply.github.com> Co-authored-by: Devin Dissanayaka <dsdissanayaka2002@gmail.com> Co-authored-by: Peter Cinibulk <petercinibulk@gmail.com> Co-authored-by: Med Ali Ezzeddine <47082236+xDaly@users.noreply.github.com>
This commit is contained in:
co-authored by
LukeGus
ZacharyZcR
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Brennan Neoh
Claude Sonnet 5
kacperpietrzyk
Max
XtraLarge
XtraLarge
Devin Dissanayaka
Peter Cinibulk
Med Ali Ezzeddine
parent
188380e8e7
commit
a64c956c5b
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertUrlMatchesDialect,
|
||||
connectRemoteDatabase,
|
||||
databaseUrl,
|
||||
DATABASE_URL_ENV,
|
||||
} from "../../../database/db/connect.js";
|
||||
|
||||
describe("databaseUrl", () => {
|
||||
it("is absent unless set", () => {
|
||||
expect(databaseUrl({})).toBeNull();
|
||||
expect(databaseUrl({ [DATABASE_URL_ENV]: " " })).toBeNull();
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(
|
||||
databaseUrl({ [DATABASE_URL_ENV]: " postgres://db/termix " }),
|
||||
).toBe("postgres://db/termix");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertUrlMatchesDialect", () => {
|
||||
it("accepts the schemes each engine answers to", () => {
|
||||
expect(() =>
|
||||
assertUrlMatchesDialect("postgres://db/termix", "postgres"),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertUrlMatchesDialect("postgresql://db/termix", "postgres"),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertUrlMatchesDialect("mysql://db/termix", "mysql"),
|
||||
).not.toThrow();
|
||||
// MariaDB speaks the MySQL protocol.
|
||||
expect(() =>
|
||||
assertUrlMatchesDialect("mariadb://db/termix", "mysql"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("is case-insensitive about the scheme", () => {
|
||||
expect(() =>
|
||||
assertUrlMatchesDialect("POSTGRES://db/termix", "postgres"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("catches a mismatch and says what is wrong", () => {
|
||||
// The failure mode this exists to prevent: a driver error thirty frames
|
||||
// down that never mentions the actual misconfiguration.
|
||||
expect(() =>
|
||||
assertUrlMatchesDialect("mysql://db/termix", "postgres"),
|
||||
).toThrow(/is a "mysql:\/\/" URL but DATABASE_DIALECT is "postgres"/);
|
||||
|
||||
expect(() =>
|
||||
assertUrlMatchesDialect("postgres://db/termix", "mysql"),
|
||||
).toThrow(/expected one of mysql:\/\/, mariadb:\/\//i);
|
||||
});
|
||||
|
||||
it("rejects sqlite, which does not use a URL", () => {
|
||||
expect(() =>
|
||||
assertUrlMatchesDialect("postgres://db/termix", "sqlite"),
|
||||
).toThrow(/does not use DATABASE_URL/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectRemoteDatabase", () => {
|
||||
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"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a mismatched URL before opening a connection", () => {
|
||||
return expect(
|
||||
connectRemoteDatabase("postgres", {
|
||||
[DATABASE_URL_ENV]: "mysql://db/termix",
|
||||
}),
|
||||
).rejects.toThrow(/DATABASE_DIALECT is "postgres"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import path from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
migrationsFolder,
|
||||
runRemoteMigrations,
|
||||
MIGRATIONS_DIR_ENV,
|
||||
} from "../../../database/db/migrate.js";
|
||||
|
||||
describe("migrationsFolder", () => {
|
||||
it("gives each engine its own folder", () => {
|
||||
// The generated SQL differs per dialect, so they cannot share one.
|
||||
expect(migrationsFolder("postgres", {})).toBe(
|
||||
path.resolve(process.cwd(), "drizzle", "postgres"),
|
||||
);
|
||||
expect(migrationsFolder("mysql", {})).toBe(
|
||||
path.resolve(process.cwd(), "drizzle", "mysql"),
|
||||
);
|
||||
});
|
||||
|
||||
it("honours an explicit root", () => {
|
||||
expect(
|
||||
migrationsFolder("postgres", { [MIGRATIONS_DIR_ENV]: "/srv/migrations" }),
|
||||
).toBe(path.join("/srv/migrations", "postgres"));
|
||||
});
|
||||
|
||||
it("ignores a blank override", () => {
|
||||
expect(migrationsFolder("mysql", { [MIGRATIONS_DIR_ENV]: " " })).toBe(
|
||||
path.resolve(process.cwd(), "drizzle", "mysql"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRemoteMigrations", () => {
|
||||
it("refuses sqlite, which builds its schema elsewhere", () => {
|
||||
return expect(
|
||||
runRemoteMigrations("sqlite", {} as never),
|
||||
).rejects.toThrow(/SQLite builds its schema in index.ts/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { drizzle as sqliteDrizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { drizzle as pgDrizzle } from "drizzle-orm/node-postgres";
|
||||
import { drizzle as mysqlDrizzle } from "drizzle-orm/mysql2";
|
||||
import { getTableConfig as sqliteTableConfig } from "drizzle-orm/sqlite-core";
|
||||
import { getTableConfig as pgTableConfig } from "drizzle-orm/pg-core";
|
||||
import { getTableConfig as mysqlTableConfig } from "drizzle-orm/mysql-core";
|
||||
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 {
|
||||
DATABASE_DIALECT_ENV,
|
||||
isDatabaseDialect,
|
||||
needsExplicitPersist,
|
||||
resolveDatabaseDialect,
|
||||
} from "../../../database/db/dialect.js";
|
||||
|
||||
describe("resolveDatabaseDialect", () => {
|
||||
it("defaults to sqlite so existing deployments are unaffected", () => {
|
||||
expect(resolveDatabaseDialect({})).toBe("sqlite");
|
||||
expect(resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "" })).toBe(
|
||||
"sqlite",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the supported engines, case-insensitively", () => {
|
||||
expect(resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "postgres" })).toBe(
|
||||
"postgres",
|
||||
);
|
||||
expect(resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "MySQL" })).toBe(
|
||||
"mysql",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an unknown engine rather than silently using sqlite", () => {
|
||||
expect(() =>
|
||||
resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "oracle" }),
|
||||
).toThrow(/Unsupported/);
|
||||
});
|
||||
|
||||
it("narrows correctly", () => {
|
||||
expect(isDatabaseDialect("mysql")).toBe(true);
|
||||
expect(isDatabaseDialect("mongo")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsExplicitPersist", () => {
|
||||
it("is true only for sqlite", () => {
|
||||
// SQLite runs in memory and is serialised back to an encrypted file, so
|
||||
// every write needs a flush. The others have already committed durably.
|
||||
expect(needsExplicitPersist("sqlite")).toBe(true);
|
||||
expect(needsExplicitPersist("postgres")).toBe(false);
|
||||
expect(needsExplicitPersist("mysql")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* schema.pg.ts and schema.mysql.ts are generated from schema.ts. These check the
|
||||
* generated output is usable rather than merely syntactically valid — the
|
||||
* repository layer's correctness rests on all three behaving the same way.
|
||||
*/
|
||||
describe("generated schemas", () => {
|
||||
it("declares the same tables in all three dialects", () => {
|
||||
const tablesOf = (schema: Record<string, unknown>) =>
|
||||
Object.keys(schema).sort();
|
||||
|
||||
expect(tablesOf(pgSchema)).toEqual(tablesOf(sqliteSchema));
|
||||
expect(tablesOf(mysqlSchema)).toEqual(tablesOf(sqliteSchema));
|
||||
// Guard against a generator that silently emits nothing.
|
||||
expect(tablesOf(sqliteSchema).length).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it("maps each column to the right storage type per dialect", () => {
|
||||
expect(sqliteSchema.users.isAdmin.getSQLType()).toBe("integer");
|
||||
expect(pgSchema.users.isAdmin.getSQLType()).toBe("boolean");
|
||||
expect(mysqlSchema.users.isAdmin.getSQLType()).toBe("boolean");
|
||||
|
||||
// A primary key must be indexable, which rules out unbounded TEXT on MySQL.
|
||||
expect(sqliteSchema.users.id.getSQLType()).toBe("text");
|
||||
expect(pgSchema.users.id.getSQLType()).toContain("varchar");
|
||||
expect(mysqlSchema.users.id.getSQLType()).toContain("varchar");
|
||||
});
|
||||
|
||||
it("spells the autoincrement key three different ways", () => {
|
||||
expect(sqliteSchema.auditLogs.id.getSQLType()).toBe("integer");
|
||||
expect(pgSchema.auditLogs.id.getSQLType()).toBe("serial");
|
||||
expect(mysqlSchema.auditLogs.id.getSQLType()).toBe("int");
|
||||
|
||||
for (const schema of [sqliteSchema, pgSchema, mysqlSchema]) {
|
||||
expect(schema.auditLogs.id.primary).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves both foreign-key behaviours", () => {
|
||||
// 80 cascade + 12 set null across the schema; set null is what keeps the
|
||||
// audit trail after a user is deleted (#1132).
|
||||
const perDialect = [
|
||||
{ schema: sqliteSchema, config: sqliteTableConfig },
|
||||
{ schema: pgSchema, config: pgTableConfig },
|
||||
{ schema: mysqlSchema, config: mysqlTableConfig },
|
||||
] as const;
|
||||
|
||||
for (const { schema, config } of perDialect) {
|
||||
const read = config as (table: unknown) => {
|
||||
foreignKeys: { onDelete?: string }[];
|
||||
};
|
||||
|
||||
const auditFks = read(schema.auditLogs).foreignKeys;
|
||||
expect(auditFks).toHaveLength(1);
|
||||
expect(auditFks[0].onDelete).toBe("set null");
|
||||
|
||||
const folderFks = read(schema.sshFolders).foreignKeys;
|
||||
expect(folderFks.map((fk) => fk.onDelete).sort()).toEqual([
|
||||
"cascade",
|
||||
"set null",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps nullability and uniqueness", () => {
|
||||
for (const schema of [sqliteSchema, pgSchema, mysqlSchema]) {
|
||||
expect(schema.auditLogs.userId.notNull).toBe(false);
|
||||
expect(schema.sshFolders.userId.notNull).toBe(true);
|
||||
expect(schema.sshFolders.syncId.isUnique).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Queries are built, never executed, so no server is required. What matters is
|
||||
* that identical repository-style code produces correct SQL for each engine.
|
||||
*/
|
||||
describe("query generation per dialect", () => {
|
||||
const sqliteDb = sqliteDrizzle(new Database(":memory:"), {
|
||||
schema: sqliteSchema,
|
||||
});
|
||||
const pgDb = pgDrizzle.mock({ schema: pgSchema });
|
||||
const mysqlDb = mysqlDrizzle.mock({ schema: mysqlSchema, mode: "default" });
|
||||
|
||||
it("quotes identifiers the way each engine expects", () => {
|
||||
const built = [
|
||||
sqliteDb
|
||||
.select()
|
||||
.from(sqliteSchema.settings)
|
||||
.where(eq(sqliteSchema.settings.key, "guac_url"))
|
||||
.toSQL(),
|
||||
pgDb
|
||||
.select()
|
||||
.from(pgSchema.settings)
|
||||
.where(eq(pgSchema.settings.key, "guac_url"))
|
||||
.toSQL(),
|
||||
mysqlDb
|
||||
.select()
|
||||
.from(mysqlSchema.settings)
|
||||
.where(eq(mysqlSchema.settings.key, "guac_url"))
|
||||
.toSQL(),
|
||||
];
|
||||
|
||||
expect(built[0].sql).toContain('"settings"');
|
||||
expect(built[1].sql).toContain('"settings"');
|
||||
expect(built[2].sql).toContain("`settings`");
|
||||
|
||||
// The value is parameterised either way, never inlined.
|
||||
for (const sql of built) {
|
||||
expect(sql.params).toEqual(["guac_url"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses each engine's placeholder style", () => {
|
||||
expect(
|
||||
pgDb
|
||||
.select()
|
||||
.from(pgSchema.users)
|
||||
.where(eq(pgSchema.users.id, "u-1"))
|
||||
.toSQL().sql,
|
||||
).toContain("$1");
|
||||
|
||||
expect(
|
||||
mysqlDb
|
||||
.select()
|
||||
.from(mysqlSchema.users)
|
||||
.where(eq(mysqlSchema.users.id, "u-1"))
|
||||
.toSQL().sql,
|
||||
).toContain("?");
|
||||
});
|
||||
|
||||
it("stores booleans as the type each engine expects", () => {
|
||||
const row = { id: "u-1", username: "alice", passwordHash: "hash" };
|
||||
|
||||
const sqliteSql = sqliteDb
|
||||
.insert(sqliteSchema.users)
|
||||
.values({ ...row, isAdmin: true })
|
||||
.toSQL();
|
||||
const pgSql = pgDb
|
||||
.insert(pgSchema.users)
|
||||
.values({ ...row, isAdmin: true })
|
||||
.toSQL();
|
||||
|
||||
// The storage difference the generator exists to absorb.
|
||||
expect(sqliteSql.params).toContain(1);
|
||||
expect(pgSql.params).toContain(true);
|
||||
});
|
||||
|
||||
it("round-trips on the engine that is actually wired up", () => {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.exec(
|
||||
`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
|
||||
);
|
||||
const db = sqliteDrizzle(sqlite, { schema: sqliteSchema });
|
||||
|
||||
db.insert(sqliteSchema.settings)
|
||||
.values({ key: "guac_url", value: "guacd:4822" })
|
||||
.run();
|
||||
|
||||
expect(db.select().from(sqliteSchema.settings).all()).toEqual([
|
||||
{ key: "guac_url", value: "guacd:4822" },
|
||||
]);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* `DB_FILE_ENCRYPTION=false` used to mean "start empty, every time".
|
||||
*
|
||||
* The database lives in memory on every backend and is serialised to disk after
|
||||
* writes; the flag only decides whether that file is ciphertext. The plain
|
||||
* branch wrote `db.sqlite` faithfully and then never read it back, so each
|
||||
* restart began with an empty database and silently discarded everything the
|
||||
* previous run had saved. The data-dir guard made it worse by confirming a
|
||||
* database was present in DATA_DIR immediately before it was thrown away.
|
||||
*/
|
||||
describe("unencrypted database persistence", () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plain-db-"));
|
||||
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 file with one row, as a previous run would have left it. */
|
||||
function writeExistingDatabase(): void {
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)");
|
||||
seed
|
||||
.prepare("INSERT INTO settings (key, value) VALUES (?, ?)")
|
||||
.run("survives_restart", "yes");
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||
seed.close();
|
||||
}
|
||||
|
||||
it("reads back what an earlier run wrote", async () => {
|
||||
writeExistingDatabase();
|
||||
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
|
||||
const row = db
|
||||
.getSqlite()
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get("survives_restart") as { value: string } | undefined;
|
||||
|
||||
expect(row?.value).toBe("yes");
|
||||
});
|
||||
|
||||
it("starts empty when there is no file yet", async () => {
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
|
||||
// Startup creates its own tables; the point is that it does not throw on a
|
||||
// missing file and does not carry rows over from nowhere.
|
||||
const row = db
|
||||
.getSqlite()
|
||||
.prepare("SELECT COUNT(*) AS count FROM users")
|
||||
.get() as { count: number };
|
||||
|
||||
expect(row.count).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores a zero-length file rather than failing to open it", async () => {
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), "");
|
||||
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await expect(db.initializeDatabase()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -17,68 +17,11 @@ describe("AlertRepository", () => {
|
||||
): Promise<AlertRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.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
|
||||
);
|
||||
|
||||
CREATE TABLE alert_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
trigger_type TEXT NOT NULL,
|
||||
threshold_value REAL,
|
||||
threshold_duration_seconds INTEGER,
|
||||
cooldown_minutes INTEGER NOT NULL DEFAULT 15,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE notification_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE alert_rule_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_id INTEGER NOT NULL,
|
||||
channel_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE alert_firings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
rule_id INTEGER NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
host_name TEXT NOT NULL,
|
||||
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at TEXT,
|
||||
value REAL,
|
||||
message TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'warning',
|
||||
acknowledged INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
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)
|
||||
VALUES (1, 'user-1', 'alpha', '127.0.0.1');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'alpha', '127.0.0.1', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new AlertRepository(context, onWrite);
|
||||
@@ -229,7 +172,7 @@ describe("AlertRepository", () => {
|
||||
expect(unacknowledged.total).toBe(0);
|
||||
|
||||
await repo.acknowledgeAllFirings("user-1");
|
||||
repo.pruneFiringsOlderThan("user-1", 0);
|
||||
await repo.pruneFiringsOlderThan("user-1", 0);
|
||||
});
|
||||
|
||||
it("loads enabled rules and notification channels for the alert engine", async () => {
|
||||
|
||||
@@ -17,28 +17,7 @@ describe("ApiKeyRepository", () => {
|
||||
}> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'admin', 'hash'),
|
||||
('user-2', 'target', 'hash');
|
||||
|
||||
@@ -17,31 +17,7 @@ describe("AuditLogRepository", () => {
|
||||
): Promise<AuditLogRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
@@ -129,4 +105,46 @@ describe("AuditLogRepository", () => {
|
||||
).logs.map((log) => log.userId),
|
||||
).toEqual(["user-2"]);
|
||||
});
|
||||
|
||||
it("keeps entries when their user is deleted, detaching instead of removing", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
userId: "user-1",
|
||||
username: "alice",
|
||||
action: "delete_host",
|
||||
resourceType: "host",
|
||||
resourceId: "9",
|
||||
success: true,
|
||||
timestamp: "2026-07-01T00:00:00.000Z",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-2",
|
||||
username: "bob",
|
||||
action: "create_host",
|
||||
resourceType: "host",
|
||||
resourceId: "8",
|
||||
success: true,
|
||||
timestamp: "2026-07-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(await repo.anonymizeByUserId("user-1")).toBe(1);
|
||||
|
||||
const { logs } = await repo.listPage({ filters: {}, limit: 10, offset: 0 });
|
||||
expect(logs).toHaveLength(2);
|
||||
|
||||
const detached = logs.find((log) => log.action === "delete_host");
|
||||
// The account is gone; the entry and its actor name are not.
|
||||
expect(detached?.userId).toBeNull();
|
||||
expect(detached?.username).toBe("alice");
|
||||
expect(logs.find((log) => log.action === "create_host")?.userId).toBe(
|
||||
"user-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports nothing to detach for a user with no entries", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
expect(await repo.anonymizeByUserId("user-2")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() }));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: logs,
|
||||
}));
|
||||
|
||||
const { TestSqliteDatabase } = await import("./test-support.js");
|
||||
const {
|
||||
AuditLogRepository,
|
||||
auditRetentionDays,
|
||||
auditMaxEntries,
|
||||
AUDIT_RETENTION_DAYS_ENV,
|
||||
AUDIT_MAX_ENTRIES_ENV,
|
||||
} = await import("../../../database/repositories/audit-log-repository.js");
|
||||
|
||||
let adapter: InstanceType<typeof TestSqliteDatabase> | null = null;
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
logs.info.mockReset();
|
||||
logs.warn.mockReset();
|
||||
for (const key of [AUDIT_RETENTION_DAYS_ENV, AUDIT_MAX_ENTRIES_ENV]) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository() {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('u-1', 'u-1', 'hash');
|
||||
`);
|
||||
return new AuditLogRepository(context);
|
||||
}
|
||||
|
||||
function daysAgo(days: number): string {
|
||||
const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
return d.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
async function seed(
|
||||
repo: Awaited<ReturnType<typeof createRepository>>,
|
||||
timestamp: string,
|
||||
action = "create_host",
|
||||
) {
|
||||
await repo.create({
|
||||
userId: "u-1",
|
||||
username: "alice",
|
||||
action,
|
||||
resourceType: "host",
|
||||
success: true,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
describe("audit retention configuration", () => {
|
||||
it("has no time limit unless one is configured", () => {
|
||||
expect(auditRetentionDays({})).toBeNull();
|
||||
expect(auditRetentionDays({ [AUDIT_RETENTION_DAYS_ENV]: "90" })).toBe(90);
|
||||
});
|
||||
|
||||
it("ignores values that are not a positive count", () => {
|
||||
for (const bad of ["0", "-5", "", "abc"]) {
|
||||
expect(
|
||||
auditRetentionDays({ [AUDIT_RETENTION_DAYS_ENV]: bad }),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the built-in cap", () => {
|
||||
expect(auditMaxEntries({})).toBe(10000);
|
||||
expect(auditMaxEntries({ [AUDIT_MAX_ENTRIES_ENV]: "250" })).toBe(250);
|
||||
expect(auditMaxEntries({ [AUDIT_MAX_ENTRIES_ENV]: "-1" })).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit retention pruning", () => {
|
||||
it("keeps everything when no retention is set", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await seed(repo, daysAgo(400));
|
||||
await seed(repo, daysAgo(1));
|
||||
|
||||
const { total } = await repo.listPage({
|
||||
filters: {},
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(total).toBe(2);
|
||||
expect(logs.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops entries past the retention window and says so", async () => {
|
||||
process.env[AUDIT_RETENTION_DAYS_ENV] = "30";
|
||||
const repo = await createRepository();
|
||||
|
||||
await seed(repo, daysAgo(90), "old_action");
|
||||
await seed(repo, daysAgo(5), "recent_action");
|
||||
|
||||
const { logs: rows } = await repo.listPage({
|
||||
filters: {},
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(rows.map((r) => r.action)).toEqual(["recent_action"]);
|
||||
|
||||
expect(logs.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("past retention"),
|
||||
expect.objectContaining({ operation: "audit_retention_prune" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when the row cap discards entries still inside the window", async () => {
|
||||
process.env[AUDIT_MAX_ENTRIES_ENV] = "5";
|
||||
const repo = await createRepository();
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await seed(repo, daysAgo(10 - i), `action_${i}`);
|
||||
}
|
||||
|
||||
// The cap is not a retention policy: these entries were still current.
|
||||
expect(logs.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("cap"),
|
||||
expect.objectContaining({
|
||||
operation: "audit_overflow_prune",
|
||||
maxEntries: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
const { total } = await repo.listPage({
|
||||
filters: {},
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
expect(total).toBeLessThan(6);
|
||||
});
|
||||
|
||||
it("stays quiet while under the cap", async () => {
|
||||
process.env[AUDIT_MAX_ENTRIES_ENV] = "100";
|
||||
const repo = await createRepository();
|
||||
|
||||
await seed(repo, daysAgo(1));
|
||||
|
||||
expect(logs.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -17,24 +17,7 @@ describe("C2sTunnelPresetRepository", () => {
|
||||
): Promise<C2sTunnelPresetRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE c2s_tunnel_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
computer_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
@@ -17,33 +17,11 @@ describe("CommandHistoryRepository", () => {
|
||||
): Promise<CommandHistoryRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE command_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
||||
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.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new CommandHistoryRepository(context, onWrite);
|
||||
|
||||
@@ -17,26 +17,7 @@ describe("DashboardServiceLinkRepository", () => {
|
||||
): Promise<DashboardServiceLinkRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE dashboard_service_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
@@ -17,22 +17,7 @@ describe("DismissedAlertRepository", () => {
|
||||
): Promise<DismissedAlertRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DATABASE_DIALECT_ENV } from "../../../database/db/dialect.js";
|
||||
|
||||
// getDb() throws unless a database was initialized; the context's handle is
|
||||
// not what this file is about.
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => ({}),
|
||||
getSqlite: () => ({}),
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn() },
|
||||
}));
|
||||
|
||||
const { createCurrentRepositoryContext, createCurrentRepositoryWriteHook } =
|
||||
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
|
||||
// construct a DatabaseContext of their own. That is why the production path
|
||||
// could report "sqlite" while connected to MySQL with CI green on all three
|
||||
// engines, and why this asserts on the real factory rather than a fixture.
|
||||
describe("createCurrentRepositoryContext", () => {
|
||||
const saved = process.env[DATABASE_DIALECT_ENV];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env[DATABASE_DIALECT_ENV];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV];
|
||||
else process.env[DATABASE_DIALECT_ENV] = saved;
|
||||
});
|
||||
|
||||
it("defaults to sqlite when nothing is configured", () => {
|
||||
expect(createCurrentRepositoryContext().dialect).toBe("sqlite");
|
||||
});
|
||||
|
||||
it("reports the configured dialect", () => {
|
||||
for (const dialect of ["sqlite", "postgres", "mysql"]) {
|
||||
process.env[DATABASE_DIALECT_ENV] = dialect;
|
||||
expect(createCurrentRepositoryContext().dialect).toBe(dialect);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unsupported dialect rather than falling back to sqlite", () => {
|
||||
process.env[DATABASE_DIALECT_ENV] = "oracle";
|
||||
expect(() => createCurrentRepositoryContext()).toThrow(/oracle/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCurrentRepositoryWriteHook", () => {
|
||||
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 persist hook only for sqlite", () => {
|
||||
process.env[DATABASE_DIALECT_ENV] = "sqlite";
|
||||
expect(createCurrentRepositoryWriteHook("test")).toBeTypeOf("function");
|
||||
|
||||
for (const dialect of ["postgres", "mysql"]) {
|
||||
process.env[DATABASE_DIALECT_ENV] = dialect;
|
||||
expect(createCurrentRepositoryWriteHook("test")).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FieldEncryptionBoundary } from "../../../database/repositories/field-encryption-boundary.js";
|
||||
|
||||
describe("FieldEncryptionBoundary", () => {
|
||||
const userDataKey = crypto.randomBytes(32);
|
||||
|
||||
it("encrypts sensitive host fields while leaving queryable metadata plaintext", () => {
|
||||
const host = {
|
||||
id: 42,
|
||||
userId: "user-1",
|
||||
name: "prod-db",
|
||||
ip: "10.0.0.5",
|
||||
username: "root",
|
||||
password: "secret",
|
||||
rdpPassword: "rdp-secret",
|
||||
};
|
||||
|
||||
const encrypted = FieldEncryptionBoundary.encryptRecord(
|
||||
"ssh_data",
|
||||
host,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
expect(encrypted.password).not.toBe("secret");
|
||||
expect(encrypted.rdpPassword).not.toBe("rdp-secret");
|
||||
expect(encrypted.ip).toBe("10.0.0.5");
|
||||
expect(encrypted.name).toBe("prod-db");
|
||||
|
||||
const decrypted = FieldEncryptionBoundary.decryptRecord(
|
||||
"ssh_data",
|
||||
encrypted,
|
||||
userDataKey,
|
||||
);
|
||||
expect(decrypted).toMatchObject(host);
|
||||
});
|
||||
|
||||
it("encrypts credential secret fields and keeps metadata plaintext", () => {
|
||||
const credential = {
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
name: "primary credential",
|
||||
authType: "key",
|
||||
key: "private-key-material",
|
||||
keyPassword: "key-password",
|
||||
};
|
||||
|
||||
const encrypted = FieldEncryptionBoundary.encryptRecord(
|
||||
"ssh_credentials",
|
||||
credential,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
expect(encrypted.key).not.toBe("private-key-material");
|
||||
expect(encrypted.keyPassword).not.toBe("key-password");
|
||||
expect(encrypted.name).toBe("primary credential");
|
||||
|
||||
expect(
|
||||
FieldEncryptionBoundary.decryptRecord(
|
||||
"ssh_credentials",
|
||||
encrypted,
|
||||
userDataKey,
|
||||
),
|
||||
).toMatchObject(credential);
|
||||
});
|
||||
|
||||
it("keeps empty and non-string sensitive values unchanged", () => {
|
||||
const encrypted = FieldEncryptionBoundary.encryptRecord(
|
||||
"ssh_data",
|
||||
{
|
||||
id: 1,
|
||||
password: "",
|
||||
key: null,
|
||||
},
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
expect(encrypted.password).toBe("");
|
||||
expect(encrypted.key).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a stable record id instead of inventing a temporary encryption context", () => {
|
||||
expect(() =>
|
||||
FieldEncryptionBoundary.encryptRecord(
|
||||
"ssh_data",
|
||||
{ password: "secret" },
|
||||
userDataKey,
|
||||
),
|
||||
).toThrow(/stable record id/);
|
||||
});
|
||||
|
||||
it("classifies sensitive, plaintext, and unknown fields", () => {
|
||||
expect(FieldEncryptionBoundary.classifyField("ssh_data", "password")).toBe(
|
||||
"sensitive",
|
||||
);
|
||||
expect(FieldEncryptionBoundary.classifyField("ssh_data", "ip")).toBe(
|
||||
"plaintext",
|
||||
);
|
||||
expect(FieldEncryptionBoundary.classifyField("ssh_data", "newField")).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,50 +17,11 @@ describe("FileManagerBookmarkRepository", () => {
|
||||
): Promise<FileManagerBookmarkRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE file_manager_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE file_manager_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
||||
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.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new FileManagerBookmarkRepository(context, onWrite);
|
||||
|
||||
@@ -17,25 +17,7 @@ describe("HomepageItemRepository", () => {
|
||||
): Promise<HomepageItemRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE homepage_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
type_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
folder_id INTEGER,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
@@ -17,22 +17,7 @@ describe("HomepageLayoutRepository", () => {
|
||||
): Promise<HomepageLayoutRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE homepage_layouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
layout TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { CredentialRepository } from "../../../database/repositories/credential-repository.js";
|
||||
@@ -21,174 +22,10 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
): Promise<{
|
||||
credentials: CredentialRepository;
|
||||
hosts: HostRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
||||
>;
|
||||
}> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
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,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
private_key TEXT,
|
||||
public_key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
detected_key_type TEXT,
|
||||
cert_public_key TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
connection_type TEXT NOT NULL DEFAULT 'ssh',
|
||||
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,
|
||||
use_warpgate INTEGER NOT NULL DEFAULT 0,
|
||||
force_keyboard_interactive TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
sudo_password TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
credential_id INTEGER,
|
||||
override_credential_username INTEGER,
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
jump_hosts TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
scp_legacy INTEGER NOT NULL DEFAULT 0,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
enable_tmux_monitor INTEGER NOT NULL DEFAULT 0,
|
||||
show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
||||
show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
enable_proxmox INTEGER NOT NULL DEFAULT 0,
|
||||
proxmox_config TEXT,
|
||||
terminal_config TEXT,
|
||||
quick_actions TEXT,
|
||||
notes TEXT,
|
||||
enable_ssh INTEGER NOT NULL DEFAULT 1,
|
||||
enable_rdp INTEGER NOT NULL DEFAULT 0,
|
||||
enable_vnc INTEGER NOT NULL DEFAULT 0,
|
||||
enable_telnet INTEGER NOT NULL DEFAULT 0,
|
||||
ssh_port INTEGER DEFAULT 22,
|
||||
rdp_port INTEGER DEFAULT 3389,
|
||||
vnc_port INTEGER DEFAULT 5900,
|
||||
telnet_port INTEGER DEFAULT 23,
|
||||
rdp_credential_id INTEGER,
|
||||
rdp_user TEXT,
|
||||
rdp_password TEXT,
|
||||
rdp_domain TEXT,
|
||||
rdp_security TEXT,
|
||||
rdp_ignore_cert INTEGER DEFAULT 0,
|
||||
vnc_credential_id INTEGER,
|
||||
vnc_password TEXT,
|
||||
vnc_user TEXT,
|
||||
telnet_user TEXT,
|
||||
telnet_password TEXT,
|
||||
telnet_credential_id INTEGER,
|
||||
rdp_auth_type TEXT,
|
||||
vnc_auth_type TEXT,
|
||||
telnet_auth_type TEXT,
|
||||
domain TEXT,
|
||||
security TEXT,
|
||||
ignore_cert INTEGER DEFAULT 0,
|
||||
guacamole_config TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
socks5_port INTEGER,
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
socks5_proxy_chain TEXT,
|
||||
mac_address TEXT,
|
||||
wol_broadcast_address TEXT,
|
||||
port_knock_sequence TEXT,
|
||||
host_key_fingerprint TEXT,
|
||||
host_key_type TEXT,
|
||||
host_key_algorithm TEXT DEFAULT 'sha256',
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
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 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
override_credential_id INTEGER,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (override_credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'user', 'hash'),
|
||||
('user-2', 'other', 'hash');
|
||||
@@ -197,7 +34,6 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
return {
|
||||
credentials: new CredentialRepository(context, onCredentialWrite),
|
||||
hosts: new HostRepository(context, onHostWrite),
|
||||
sqlite: context.sqlite!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,9 +60,9 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
// deterministically observable regardless of clock resolution --
|
||||
// the sync engine's last-write-wins conflict resolution depends on
|
||||
// every mutating update actually advancing this column.
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
|
||||
.run("2000-01-01 00:00:00", created.id);
|
||||
await adapter!.run(
|
||||
sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`,
|
||||
);
|
||||
|
||||
const updated = await repo.credentials.updateForUser("user-1", created.id, {
|
||||
folder: "ops",
|
||||
@@ -342,23 +178,27 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
const raw = repo.sqlite
|
||||
.prepare("SELECT password FROM ssh_credentials WHERE id = ?")
|
||||
.get(created.id) as { password: string };
|
||||
const raw = (
|
||||
await adapter!.query(
|
||||
sql`SELECT password FROM ssh_credentials WHERE id = ${created.id}`,
|
||||
)
|
||||
)[0] as { password: string };
|
||||
|
||||
expect(raw.password).toBe("user-encrypted-password");
|
||||
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
|
||||
.run("2000-01-01 00:00:00", created.id);
|
||||
await adapter!.run(
|
||||
sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`,
|
||||
);
|
||||
|
||||
await repo.credentials.updateEncryptedForUser("user-1", created.id, {
|
||||
password: "updated-secret",
|
||||
});
|
||||
|
||||
const updatedRaw = repo.sqlite
|
||||
.prepare("SELECT password, updated_at FROM ssh_credentials WHERE id = ?")
|
||||
.get(created.id) as { password: string; updated_at: string };
|
||||
const updatedRaw = (
|
||||
await adapter!.query(
|
||||
sql`SELECT password, updated_at FROM ssh_credentials WHERE id = ${created.id}`,
|
||||
)
|
||||
)[0] as { password: string; updated_at: string };
|
||||
|
||||
expect(updatedRaw.password).toBe("user-encrypted-password");
|
||||
expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
|
||||
@@ -410,9 +250,9 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
authType: "password",
|
||||
folder: "prod",
|
||||
});
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
|
||||
.run("2000-01-01 00:00:00", primary.id);
|
||||
await adapter!.run(
|
||||
sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${primary.id}`,
|
||||
);
|
||||
onWrite.mockClear();
|
||||
|
||||
await expect(
|
||||
@@ -423,9 +263,11 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
expect(await repo.credentials.listFolders("user-2")).toEqual(["prod"]);
|
||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||
|
||||
const renamedRow = repo.sqlite
|
||||
.prepare("SELECT updated_at FROM ssh_credentials WHERE id = ?")
|
||||
.get(primary.id) as { updated_at: string };
|
||||
const renamedRow = (
|
||||
await adapter!.query(
|
||||
sql`SELECT updated_at FROM ssh_credentials WHERE id = ${primary.id}`,
|
||||
)
|
||||
)[0] as { updated_at: string };
|
||||
expect(renamedRow.updated_at).not.toBe("2000-01-01 00:00:00");
|
||||
});
|
||||
|
||||
@@ -467,9 +309,9 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
(await repo.hosts.listByUserId("user-1")).map((item) => item.id),
|
||||
).toEqual([host.id]);
|
||||
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?")
|
||||
.run("2000-01-01 00:00:00", host.id);
|
||||
await adapter!.run(
|
||||
sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${host.id}`,
|
||||
);
|
||||
|
||||
const updated = await repo.hosts.updateForUser("user-1", host.id, {
|
||||
name: "web-1-renamed",
|
||||
@@ -511,23 +353,27 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
const raw = repo.sqlite
|
||||
.prepare("SELECT password FROM ssh_data WHERE id = ?")
|
||||
.get(created.id) as { password: string };
|
||||
const raw = (
|
||||
await adapter!.query(
|
||||
sql`SELECT password FROM ssh_data WHERE id = ${created.id}`,
|
||||
)
|
||||
)[0] as { password: string };
|
||||
|
||||
expect(raw.password).toBe("encrypted-host-password");
|
||||
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?")
|
||||
.run("2000-01-01 00:00:00", created.id);
|
||||
await adapter!.run(
|
||||
sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`,
|
||||
);
|
||||
|
||||
await repo.hosts.updateEncryptedForUser("user-1", created.id, {
|
||||
password: "updated-secret",
|
||||
});
|
||||
|
||||
const updatedRaw = repo.sqlite
|
||||
.prepare("SELECT password, updated_at FROM ssh_data WHERE id = ?")
|
||||
.get(created.id) as { password: string; updated_at: string };
|
||||
const updatedRaw = (
|
||||
await adapter!.query(
|
||||
sql`SELECT password, updated_at FROM ssh_data WHERE id = ${created.id}`,
|
||||
)
|
||||
)[0] as { password: string; updated_at: string };
|
||||
|
||||
expect(updatedRaw.password).toBe("encrypted-host-password");
|
||||
expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
|
||||
@@ -655,9 +501,9 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
repo.sqlite
|
||||
.prepare("UPDATE ssh_data SET updated_at = ? WHERE id IN (?, ?)")
|
||||
.run("2000-01-01 00:00:00", first.id, second.id);
|
||||
await adapter!.run(
|
||||
sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id IN (${first.id}, ${second.id})`,
|
||||
);
|
||||
onWrite.mockClear();
|
||||
|
||||
const states = await repo.hosts.listBulkUpdateState("user-1", [
|
||||
@@ -726,11 +572,9 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
authType: "password",
|
||||
});
|
||||
|
||||
repo.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO host_access (host_id, user_id, granted_by) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(host.id, "user-2", "user-1");
|
||||
await adapter!.run(
|
||||
sql`INSERT INTO host_access (host_id, user_id, granted_by) VALUES (${host.id}, ${"user-2"}, ${"user-1"})`,
|
||||
);
|
||||
|
||||
expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1);
|
||||
expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { HostFolderRepository } from "../../../database/repositories/host-folder-repository.js";
|
||||
@@ -14,153 +15,21 @@ describe("HostFolderRepository", () => {
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<{
|
||||
repository: HostFolderRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
||||
>;
|
||||
}> {
|
||||
): Promise<{ repository: HostFolderRepository }> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
connection_type TEXT NOT NULL DEFAULT 'ssh',
|
||||
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,
|
||||
use_warpgate INTEGER NOT NULL DEFAULT 0,
|
||||
force_keyboard_interactive TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
sudo_password TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
credential_id INTEGER,
|
||||
override_credential_username INTEGER,
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
jump_hosts TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
scp_legacy INTEGER NOT NULL DEFAULT 0,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
enable_tmux_monitor INTEGER NOT NULL DEFAULT 0,
|
||||
show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
||||
show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
enable_proxmox INTEGER NOT NULL DEFAULT 0,
|
||||
proxmox_config TEXT,
|
||||
terminal_config TEXT,
|
||||
quick_actions TEXT,
|
||||
notes TEXT,
|
||||
enable_ssh INTEGER NOT NULL DEFAULT 1,
|
||||
enable_rdp INTEGER NOT NULL DEFAULT 0,
|
||||
enable_vnc INTEGER NOT NULL DEFAULT 0,
|
||||
enable_telnet INTEGER NOT NULL DEFAULT 0,
|
||||
ssh_port INTEGER DEFAULT 22,
|
||||
rdp_port INTEGER DEFAULT 3389,
|
||||
vnc_port INTEGER DEFAULT 5900,
|
||||
telnet_port INTEGER DEFAULT 23,
|
||||
rdp_credential_id INTEGER,
|
||||
rdp_user TEXT,
|
||||
rdp_password TEXT,
|
||||
rdp_domain TEXT,
|
||||
rdp_security TEXT,
|
||||
rdp_ignore_cert INTEGER DEFAULT 0,
|
||||
vnc_credential_id INTEGER,
|
||||
vnc_password TEXT,
|
||||
vnc_user TEXT,
|
||||
telnet_user TEXT,
|
||||
telnet_password TEXT,
|
||||
telnet_credential_id INTEGER,
|
||||
rdp_auth_type TEXT,
|
||||
vnc_auth_type TEXT,
|
||||
telnet_auth_type TEXT,
|
||||
domain TEXT,
|
||||
security TEXT,
|
||||
ignore_cert INTEGER DEFAULT 0,
|
||||
guacamole_config TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
socks5_port INTEGER,
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
socks5_proxy_chain TEXT,
|
||||
mac_address TEXT,
|
||||
wol_broadcast_address TEXT,
|
||||
port_knock_sequence TEXT,
|
||||
host_key_fingerprint TEXT,
|
||||
host_key_type TEXT,
|
||||
host_key_algorithm TEXT DEFAULT 'sha256',
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
credential_id INTEGER,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, folder, auth_type, username)
|
||||
VALUES (1, 'user-1', 'cred-one', 'prod', 'password', 'root'),
|
||||
(2, 'user-1', 'cred-two', 'prod / api', 'password', 'root'),
|
||||
(3, 'user-2', 'cred-other', 'prod', 'password', 'root');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, auth_type)
|
||||
VALUES
|
||||
(1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'prod', 'password'),
|
||||
(2, 'user-1', 'two', '10.0.0.2', 22, 'root', 'prod / api', 'password'),
|
||||
(3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'prod', 'password');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, folder, auth_type)
|
||||
VALUES
|
||||
(1, 'user-1', 'cred-one', 'prod', 'password'),
|
||||
(2, 'user-1', 'cred-two', 'prod / api', 'password'),
|
||||
(3, 'user-2', 'cred-other', 'prod', 'password');
|
||||
INSERT INTO ssh_folders (id, user_id, name, color, icon)
|
||||
VALUES
|
||||
(1, 'user-1', 'prod', '#111111', 'server'),
|
||||
@@ -168,15 +37,12 @@ describe("HostFolderRepository", () => {
|
||||
(3, 'user-2', 'prod', '#333333', 'user');
|
||||
`);
|
||||
|
||||
return {
|
||||
repository: new HostFolderRepository(context, onWrite),
|
||||
sqlite: context.sqlite!,
|
||||
};
|
||||
return { repository: new HostFolderRepository(context, onWrite) };
|
||||
}
|
||||
|
||||
it("renames folders across hosts, credentials, and folder records", async () => {
|
||||
let writes = 0;
|
||||
const { repository, sqlite } = await createRepository(() => {
|
||||
const { repository } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
@@ -189,22 +55,23 @@ describe("HostFolderRepository", () => {
|
||||
),
|
||||
).resolves.toEqual({ updatedHosts: 2, updatedCredentials: 2 });
|
||||
|
||||
// Portable on purpose: the rename builds the child path with string
|
||||
// concatenation, which is the one place a dialect difference shows up as
|
||||
// wrong data rather than an error.
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT folder FROM ssh_data WHERE user_id = ? ORDER BY id")
|
||||
.all("user-1"),
|
||||
await adapter!.query(
|
||||
sql`SELECT folder FROM ssh_data WHERE user_id = 'user-1' ORDER BY id`,
|
||||
),
|
||||
).toEqual([{ folder: "ops" }, { folder: "ops / api" }]);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT folder FROM ssh_credentials WHERE user_id = ? ORDER BY id",
|
||||
)
|
||||
.all("user-1"),
|
||||
await adapter!.query(
|
||||
sql`SELECT folder FROM ssh_credentials WHERE user_id = 'user-1' ORDER BY id`,
|
||||
),
|
||||
).toEqual([{ folder: "ops" }, { folder: "ops / api" }]);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT name FROM ssh_folders WHERE user_id = ? ORDER BY id")
|
||||
.all("user-1"),
|
||||
await adapter!.query(
|
||||
sql`SELECT name FROM ssh_folders WHERE user_id = 'user-1' ORDER BY id`,
|
||||
),
|
||||
).toEqual([{ name: "ops" }, { name: "ops / api" }]);
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
@@ -269,7 +136,7 @@ describe("HostFolderRepository", () => {
|
||||
|
||||
it("lists and deletes hosts and folder records in a folder tree", async () => {
|
||||
let writes = 0;
|
||||
const { repository, sqlite } = await createRepository(() => {
|
||||
const { repository } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
@@ -278,28 +145,28 @@ describe("HostFolderRepository", () => {
|
||||
|
||||
await repository.deleteHostsAndFolderRecords("user-1", "prod");
|
||||
|
||||
expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual(
|
||||
[{ id: 3 }],
|
||||
);
|
||||
expect(
|
||||
sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(),
|
||||
await adapter!.query(sql`SELECT id FROM ssh_data ORDER BY id`),
|
||||
).toEqual([{ id: 3 }]);
|
||||
expect(
|
||||
await adapter!.query(sql`SELECT id FROM ssh_folders ORDER BY id`),
|
||||
).toEqual([{ id: 3 }]);
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes folder records for a user", async () => {
|
||||
let writes = 0;
|
||||
const { repository, sqlite } = await createRepository(() => {
|
||||
const { repository } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await expect(repository.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
|
||||
expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual(
|
||||
[{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
);
|
||||
expect(
|
||||
sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(),
|
||||
await adapter!.query(sql`SELECT id FROM ssh_data ORDER BY id`),
|
||||
).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
expect(
|
||||
await adapter!.query(sql`SELECT id FROM ssh_folders ORDER BY id`),
|
||||
).toEqual([{ id: 3 }]);
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
@@ -17,44 +17,11 @@ describe("HostHealthRepository", () => {
|
||||
): Promise<HostHealthRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE host_health_checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
checks TEXT NOT NULL,
|
||||
interval_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE host_health_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
check_id TEXT NOT NULL,
|
||||
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ok INTEGER NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
detail TEXT
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two');
|
||||
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-2', 'two', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO host_health_checks (
|
||||
user_id, host_id, checks, interval_seconds, created_at, updated_at
|
||||
)
|
||||
|
||||
@@ -17,26 +17,13 @@ describe("HostMetricsHistoryRepository", () => {
|
||||
): Promise<HostMetricsHistoryRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'user-1', 'hash'),
|
||||
('user-2', 'user-2', 'hash');
|
||||
|
||||
CREATE TABLE host_metrics_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
cpu_percent REAL,
|
||||
mem_percent REAL,
|
||||
disk_percent REAL,
|
||||
net_rx_bytes INTEGER,
|
||||
net_tx_bytes INTEGER
|
||||
);
|
||||
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two');
|
||||
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-2', 'two', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO host_metrics_history (
|
||||
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
|
||||
)
|
||||
@@ -78,7 +65,7 @@ describe("HostMetricsHistoryRepository", () => {
|
||||
it("prunes old history for a host only", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
repo.pruneOlderThan(1, 1);
|
||||
await repo.pruneOlderThan(1, 1);
|
||||
|
||||
const rows = await repo.listRange(
|
||||
1,
|
||||
|
||||
@@ -17,33 +17,11 @@ describe("HostMetricsPreferenceRepository", () => {
|
||||
): Promise<HostMetricsPreferenceRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.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 NOT NULL,
|
||||
stats_config TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE host_metrics_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
layout TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
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, stats_config)
|
||||
VALUES (1, 'user-1', 'one', '{}'), (2, 'user-2', 'two', '{}');
|
||||
INSERT INTO ssh_data (id, user_id, name, stats_config, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'one', '{}', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '{}', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO host_metrics_preferences (
|
||||
user_id, host_id, layout, created_at, updated_at
|
||||
)
|
||||
|
||||
@@ -27,162 +27,15 @@ describe("HostResolutionRepository", () => {
|
||||
): Promise<HostResolutionRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.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,
|
||||
connection_type TEXT NOT NULL DEFAULT 'ssh',
|
||||
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,
|
||||
use_warpgate INTEGER NOT NULL DEFAULT 0,
|
||||
force_keyboard_interactive TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
sudo_password TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
credential_id INTEGER,
|
||||
override_credential_username INTEGER,
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
jump_hosts TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
scp_legacy INTEGER NOT NULL DEFAULT 0,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
enable_tmux_monitor INTEGER NOT NULL DEFAULT 0,
|
||||
show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
||||
show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
enable_proxmox INTEGER NOT NULL DEFAULT 0,
|
||||
proxmox_config TEXT,
|
||||
terminal_config TEXT,
|
||||
quick_actions TEXT,
|
||||
notes TEXT,
|
||||
enable_ssh INTEGER NOT NULL DEFAULT 1,
|
||||
enable_rdp INTEGER NOT NULL DEFAULT 0,
|
||||
enable_vnc INTEGER NOT NULL DEFAULT 0,
|
||||
enable_telnet INTEGER NOT NULL DEFAULT 0,
|
||||
ssh_port INTEGER DEFAULT 22,
|
||||
rdp_port INTEGER DEFAULT 3389,
|
||||
vnc_port INTEGER DEFAULT 5900,
|
||||
telnet_port INTEGER DEFAULT 23,
|
||||
rdp_credential_id INTEGER,
|
||||
rdp_user TEXT,
|
||||
rdp_password TEXT,
|
||||
rdp_domain TEXT,
|
||||
rdp_security TEXT,
|
||||
rdp_ignore_cert INTEGER DEFAULT 0,
|
||||
vnc_credential_id INTEGER,
|
||||
vnc_password TEXT,
|
||||
vnc_user TEXT,
|
||||
telnet_user TEXT,
|
||||
telnet_password TEXT,
|
||||
telnet_credential_id INTEGER,
|
||||
rdp_auth_type TEXT,
|
||||
vnc_auth_type TEXT,
|
||||
telnet_auth_type TEXT,
|
||||
domain TEXT,
|
||||
security TEXT,
|
||||
ignore_cert INTEGER DEFAULT 0,
|
||||
guacamole_config TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
socks5_port INTEGER,
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
socks5_proxy_chain TEXT,
|
||||
mac_address TEXT,
|
||||
wol_broadcast_address TEXT,
|
||||
port_knock_sequence TEXT,
|
||||
host_key_fingerprint TEXT,
|
||||
host_key_type TEXT,
|
||||
host_key_algorithm TEXT DEFAULT 'sha256',
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
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,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
private_key TEXT,
|
||||
public_key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
detected_key_type TEXT,
|
||||
cert_public_key TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
sync_id 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 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
override_credential_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
credential_id INTEGER,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_credentials (
|
||||
id, user_id, name, auth_type, username, password, private_key, key_password
|
||||
)
|
||||
VALUES
|
||||
(7, 'user-1', 'owner', 'password', 'root', 'secret', NULL, NULL),
|
||||
(8, 'user-2', 'override', 'key', 'alice', NULL, 'private', 'pass');
|
||||
INSERT INTO ssh_data (
|
||||
id, user_id, name, ip, port, username, auth_type, credential_id,
|
||||
tunnel_connections
|
||||
@@ -191,21 +44,15 @@ describe("HostResolutionRepository", () => {
|
||||
(1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password', 7, '[{"autoStart":true}]'),
|
||||
(2, 'user-1', 'db', '10.0.0.2', 22, 'admin', 'none', NULL, NULL),
|
||||
(3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'none', NULL, '[{"autoStart":false}]');
|
||||
INSERT INTO ssh_credentials (
|
||||
id, user_id, name, auth_type, username, password, private_key, key_password
|
||||
)
|
||||
VALUES
|
||||
(7, 'user-1', 'owner', 'password', 'root', 'secret', NULL, NULL),
|
||||
(8, 'user-2', 'override', 'key', 'alice', NULL, 'private', 'pass');
|
||||
INSERT INTO host_access (
|
||||
host_id, user_id, granted_by, permission_level, override_credential_id
|
||||
)
|
||||
VALUES (1, 'user-2', 'user-1', 'execute', 8);
|
||||
INSERT INTO ssh_folders (user_id, name, credential_id)
|
||||
VALUES
|
||||
('user-1', 'switches', 7),
|
||||
('user-1', 'switches / floor1', NULL),
|
||||
('user-1', 'no-cred', NULL);
|
||||
INSERT INTO host_access (
|
||||
host_id, user_id, granted_by, permission_level
|
||||
)
|
||||
VALUES (1, 'user-2', 'user-1', 'execute');
|
||||
`);
|
||||
|
||||
return new HostResolutionRepository(context, onWrite);
|
||||
@@ -297,7 +144,12 @@ describe("HostResolutionRepository", () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
const rows = await repository.listHostRowsForAccessList("user-2", [
|
||||
{ hostId: 1, permissionLevel: "execute", expiresAt: null },
|
||||
{ hostId: 1, permissionLevel: "view", expiresAt: null },
|
||||
{
|
||||
hostId: 1,
|
||||
permissionLevel: "manage",
|
||||
expiresAt: "2026-07-01T00:00:00.000Z",
|
||||
},
|
||||
{ hostId: 3, permissionLevel: "view", expiresAt: null },
|
||||
{ hostId: 999, permissionLevel: "view", expiresAt: null },
|
||||
]);
|
||||
@@ -316,8 +168,8 @@ describe("HostResolutionRepository", () => {
|
||||
userId: "user-1",
|
||||
ownerId: "user-1",
|
||||
isShared: true,
|
||||
permissionLevel: "execute",
|
||||
expiresAt: null,
|
||||
permissionLevel: "manage",
|
||||
expiresAt: "2026-07-01T00:00:00.000Z",
|
||||
});
|
||||
expect(DataCrypto.decryptRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -503,17 +355,6 @@ describe("HostResolutionRepository", () => {
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("loads override credential ids for shared host resolution", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
await expect(
|
||||
repository.findOverrideCredentialId(1, "user-2"),
|
||||
).resolves.toBe(8);
|
||||
await expect(
|
||||
repository.findOverrideCredentialId(1, "user-1"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("resolves a folder's assigned credential, walking up to parent folders", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
insertedId,
|
||||
rowsAffected,
|
||||
supportsReturning,
|
||||
} from "../../../database/repositories/mutation-result.js";
|
||||
|
||||
describe("rowsAffected", () => {
|
||||
it("counts a returning() array from sqlite or postgres", () => {
|
||||
expect(rowsAffected([{ id: 1 }, { id: 2 }, { id: 3 }])).toBe(3);
|
||||
expect(rowsAffected([])).toBe(0);
|
||||
});
|
||||
|
||||
it("reads affectedRows from a mysql write result", () => {
|
||||
expect(rowsAffected({ affectedRows: 4, insertId: 0 })).toBe(4);
|
||||
expect(rowsAffected({ affectedRows: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
it("reads changes from a better-sqlite3 write result", () => {
|
||||
// The shape of a write with no .returning() attached — verified against
|
||||
// the driver, not assumed.
|
||||
expect(rowsAffected({ changes: 1, lastInsertRowid: 7 })).toBe(1);
|
||||
expect(rowsAffected({ changes: 0, lastInsertRowid: 7 })).toBe(0);
|
||||
});
|
||||
|
||||
it("reads rowCount from a node-postgres write result", () => {
|
||||
expect(rowsAffected({ rowCount: 3, rows: [], command: "DELETE" })).toBe(3);
|
||||
});
|
||||
|
||||
it("unwraps the [header, fields] tuple mysql2 returns", () => {
|
||||
expect(rowsAffected([{ affectedRows: 2 }, []])).toBe(2);
|
||||
});
|
||||
|
||||
it("does not mistake a returning() array for a mysql header", () => {
|
||||
// A single returned row is one row, not whatever affectedRows might say.
|
||||
expect(rowsAffected([{ id: 7 }])).toBe(1);
|
||||
});
|
||||
|
||||
it("reports zero for a shape it does not recognise", () => {
|
||||
expect(rowsAffected(undefined)).toBe(0);
|
||||
expect(rowsAffected(null)).toBe(0);
|
||||
expect(rowsAffected({})).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertedId", () => {
|
||||
it("reads the id from a returning() array", () => {
|
||||
expect(insertedId([{ id: 42 }])).toBe(42);
|
||||
});
|
||||
|
||||
it("reads insertId from a mysql write result", () => {
|
||||
expect(insertedId({ affectedRows: 1, insertId: 42 })).toBe(42);
|
||||
expect(insertedId([{ affectedRows: 1, insertId: 42 }, []])).toBe(42);
|
||||
});
|
||||
|
||||
it("treats mysql's zero insertId as absent", () => {
|
||||
// MySQL reports 0 when the table has no autoincrement column.
|
||||
expect(insertedId({ affectedRows: 1, insertId: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it("reads lastInsertRowid from better-sqlite3, as number or bigint", () => {
|
||||
expect(insertedId({ changes: 1, lastInsertRowid: 9 })).toBe(9);
|
||||
expect(insertedId({ changes: 1, lastInsertRowid: 9n })).toBe(9);
|
||||
expect(insertedId({ changes: 1, lastInsertRowid: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when nothing was inserted", () => {
|
||||
expect(insertedId([])).toBeNull();
|
||||
expect(insertedId({})).toBeNull();
|
||||
expect(insertedId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a non-numeric id", () => {
|
||||
// Tables keyed by a text id, e.g. users.
|
||||
expect(insertedId([{ id: "u-1" }])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsReturning", () => {
|
||||
it("is false only for mysql", () => {
|
||||
expect(supportsReturning("sqlite")).toBe(true);
|
||||
expect(supportsReturning("postgres")).toBe(true);
|
||||
// No RETURNING clause in MySQL, and drizzle's mysql-core does not expose
|
||||
// the method — call sites that need rows back must read first.
|
||||
expect(supportsReturning("mysql")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -17,23 +17,7 @@ describe("NetworkTopologyRepository", () => {
|
||||
): Promise<NetworkTopologyRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE network_topology (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
topology TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
@@ -17,29 +17,12 @@ describe("OpenTabRepository", () => {
|
||||
): Promise<OpenTabRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE user_open_tabs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
tab_type TEXT NOT NULL,
|
||||
host_id INTEGER,
|
||||
label TEXT NOT NULL,
|
||||
tab_order INTEGER NOT NULL DEFAULT 0,
|
||||
backend_session_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
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', 'host-1', '10.0.0.1', 22, 'root', 'password'),
|
||||
(2, 'user-1', 'host-2', '10.0.0.2', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new OpenTabRepository(context, onWrite);
|
||||
|
||||
@@ -17,39 +17,11 @@ describe("OpksshTokenRepository", () => {
|
||||
): Promise<OpksshTokenRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE opkssh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
ssh_cert TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
email TEXT,
|
||||
sub TEXT,
|
||||
issuer TEXT,
|
||||
audience TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used TEXT,
|
||||
UNIQUE(user_id, host_id)
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
||||
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.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO opkssh_tokens (
|
||||
user_id, host_id, ssh_cert, private_key, email, expires_at
|
||||
)
|
||||
|
||||
@@ -18,112 +18,30 @@ describe("RbacAccessRepository", () => {
|
||||
): Promise<RbacAccessRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
permissions 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 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
override_credential_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE shared_host_secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
source_type TEXT NOT NULL DEFAULT 'credential',
|
||||
original_credential_id INTEGER,
|
||||
encrypted_username TEXT,
|
||||
encrypted_auth_type TEXT,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
encrypted_domain TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(host_access_id, target_user_id, protocol)
|
||||
);
|
||||
|
||||
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,
|
||||
credential_id INTEGER,
|
||||
rdp_credential_id INTEGER,
|
||||
vnc_credential_id INTEGER,
|
||||
telnet_credential_id INTEGER,
|
||||
folder TEXT,
|
||||
tags TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE snippets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
host_filter TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE snippet_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snippet_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
|
||||
VALUES
|
||||
('admin', 'admin', 'hash', 1, 0),
|
||||
('user-1', 'alice', 'hash', 0, 0),
|
||||
('owner-1', 'owner', 'hash', 0, 0);
|
||||
|
||||
INSERT INTO roles (id, name, display_name, is_system)
|
||||
VALUES (7, 'ops', 'Operations', 0);
|
||||
|
||||
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
|
||||
(123, 'admin', 'cred-123', 'root', 'password'),
|
||||
(124, 'admin', 'cred-124', 'root', 'password'),
|
||||
(125, 'admin', 'cred-125', 'root', 'password'),
|
||||
(126, 'admin', 'cred-126', 'root', 'password');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES
|
||||
(43, 'admin', 'host-43', '10.0.0.43', 22, 'root', 'password');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES
|
||||
(44, 'admin', 'host-44', '10.0.0.45', 22, 'root', 'password');
|
||||
INSERT INTO ssh_data (
|
||||
id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags
|
||||
)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux');
|
||||
|
||||
id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags, auth_type)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux', 'password');
|
||||
INSERT INTO snippets (id, user_id, name, content)
|
||||
VALUES
|
||||
(99, 'owner-1', 'deploy', 'echo deploy'),
|
||||
(100, 'owner-1', 'rollback', 'echo rollback');
|
||||
INSERT INTO host_access (
|
||||
id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
|
||||
)
|
||||
@@ -131,23 +49,18 @@ describe("RbacAccessRepository", () => {
|
||||
(1, 42, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'),
|
||||
(2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'),
|
||||
(5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z');
|
||||
|
||||
INSERT INTO shared_host_secrets (
|
||||
id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type
|
||||
)
|
||||
VALUES
|
||||
(8, 2, 'user-1', 'ssh', 'credential', 123, 'enc-user', 'enc-auth'),
|
||||
(9, 2, 'user-1', 'rdp', 'inline', NULL, 'enc-rdp-user', 'direct');
|
||||
|
||||
INSERT INTO snippets (id, user_id, name, content)
|
||||
VALUES (99, 'owner-1', 'deploy', 'echo deploy');
|
||||
|
||||
INSERT INTO snippet_access (
|
||||
id, snippet_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
|
||||
)
|
||||
VALUES
|
||||
(3, 99, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'),
|
||||
(4, 99, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z');
|
||||
INSERT INTO shared_host_secrets (
|
||||
id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type
|
||||
)
|
||||
VALUES
|
||||
(8, 2, 'user-1', 'ssh', 'credential', 123, 'enc-user', 'enc-auth'),
|
||||
(9, 2, 'user-1', 'rdp', 'inline', NULL, 'enc-rdp-user', 'direct');
|
||||
`);
|
||||
|
||||
return new RbacAccessRepository(context, onWrite);
|
||||
@@ -436,11 +349,6 @@ describe("RbacAccessRepository", () => {
|
||||
const directAccess = await repo.findDirectHostAccess(42, "user-1");
|
||||
expect(directAccess?.id).toBe(1);
|
||||
|
||||
await repo.updateHostAccessOverrideCredential(1, 123);
|
||||
expect(
|
||||
(await repo.findDirectHostAccess(42, "user-1"))?.overrideCredentialId,
|
||||
).toBe(123);
|
||||
|
||||
await repo.touchHostAccess(1, "2026-06-26T03:00:00.000Z");
|
||||
expect(
|
||||
(await repo.findDirectHostAccess(42, "user-1"))?.lastAccessedAt,
|
||||
@@ -448,7 +356,7 @@ describe("RbacAccessRepository", () => {
|
||||
|
||||
await repo.revokeHostAccess(1, 42);
|
||||
expect(await repo.findDirectHostAccess(42, "user-1")).toBeNull();
|
||||
expect(writeCount).toBe(5);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
|
||||
it("finds active host access and deletes expired host access", async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js";
|
||||
@@ -16,40 +17,14 @@ describe("RecentActivityRepository", () => {
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<{
|
||||
repository: RecentActivityRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
||||
>;
|
||||
}> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE recent_activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
host_name TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
||||
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.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp)
|
||||
VALUES
|
||||
(1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'),
|
||||
@@ -59,13 +34,12 @@ describe("RecentActivityRepository", () => {
|
||||
|
||||
return {
|
||||
repository: new RecentActivityRepository(context, onWrite),
|
||||
sqlite: context.sqlite!,
|
||||
};
|
||||
}
|
||||
|
||||
it("lists, creates, and trims recent activity", async () => {
|
||||
let writeCount = 0;
|
||||
const { repository, sqlite } = await createRepository(() => {
|
||||
const { repository } = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
@@ -88,11 +62,9 @@ describe("RecentActivityRepository", () => {
|
||||
|
||||
expect(await repository.trimUserActivity("user-1", 2)).toBe(1);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT id FROM recent_activity WHERE user_id = ? ORDER BY timestamp DESC",
|
||||
)
|
||||
.all("user-1"),
|
||||
await adapter!.query(
|
||||
sql`SELECT id FROM recent_activity WHERE user_id = 'user-1' ORDER BY timestamp DESC`,
|
||||
),
|
||||
).toEqual([{ id: created.id }, { id: 2 }]);
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deleteReturning,
|
||||
updateReturning,
|
||||
} from "../../../database/repositories/returning.js";
|
||||
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
|
||||
import type { DatabaseDialect } from "../../../database/db/dialect.js";
|
||||
|
||||
/**
|
||||
* The MySQL path cannot be exercised against a real engine here, and its whole
|
||||
* correctness is an ordering property: an update must be read AFTER the write,
|
||||
* a delete BEFORE it. Get either backwards and the rows describe the wrong
|
||||
* state — silently, with no error anywhere.
|
||||
*
|
||||
* So the drizzle handle is stubbed and the order of calls is recorded.
|
||||
*/
|
||||
function recordingContext(dialect: DatabaseDialect) {
|
||||
const calls: string[] = [];
|
||||
const rows = [{ id: 1, name: "before" }];
|
||||
|
||||
const chain = (label: string, result: unknown) => {
|
||||
calls.push(label);
|
||||
const thenable = {
|
||||
set: () => thenable,
|
||||
from: () => thenable,
|
||||
where: () => thenable,
|
||||
returning: () => Promise.resolve(result),
|
||||
then: (resolve: (v: unknown) => void) =>
|
||||
Promise.resolve(result).then(resolve),
|
||||
};
|
||||
return thenable;
|
||||
};
|
||||
|
||||
const db = {
|
||||
update: () => chain("update", rows),
|
||||
delete: () => chain("delete", rows),
|
||||
select: () => chain("select", rows),
|
||||
transaction: (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
calls.push("begin");
|
||||
return fn(db).then((value) => {
|
||||
calls.push("commit");
|
||||
return value;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
context: { dialect, drizzle: db } as unknown as DatabaseContext,
|
||||
calls,
|
||||
};
|
||||
}
|
||||
|
||||
const where = sql`id = 1`;
|
||||
|
||||
describe("updateReturning", () => {
|
||||
it.each(["sqlite", "postgres"] as const)(
|
||||
"uses a single statement on %s, where RETURNING exists",
|
||||
async (dialect) => {
|
||||
const { context, calls } = recordingContext(dialect);
|
||||
await updateReturning(context, {} as never, {}, where);
|
||||
expect(calls).toEqual(["update"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("on mysql, writes first and reads the new state after", async () => {
|
||||
const { context, calls } = recordingContext("mysql");
|
||||
await updateReturning(context, {} as never, {}, where);
|
||||
|
||||
// Reading first would return the values the update replaced.
|
||||
expect(calls).toEqual(["begin", "update", "select", "commit"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateReturning, when the read-back cannot find the rows", () => {
|
||||
/**
|
||||
* The failure mode: an update that changes a column its own `where` filters
|
||||
* on. MySQL writes the rows, then the re-read matches nothing. Returning []
|
||||
* would be indistinguishable from "matched nothing" and silently wrong.
|
||||
*/
|
||||
function contextThatWritesButCannotReadBack() {
|
||||
const chain = (result: unknown) => {
|
||||
const thenable: Record<string, unknown> = {
|
||||
set: () => thenable,
|
||||
from: () => thenable,
|
||||
where: () => thenable,
|
||||
then: (resolve: (v: unknown) => void) =>
|
||||
Promise.resolve(result).then(resolve),
|
||||
};
|
||||
return thenable;
|
||||
};
|
||||
const db = {
|
||||
update: () => chain({ affectedRows: 3 }),
|
||||
select: () => chain([]),
|
||||
transaction: (fn: (tx: unknown) => Promise<unknown>) => fn(db),
|
||||
};
|
||||
return { dialect: "mysql", drizzle: db } as unknown as DatabaseContext;
|
||||
}
|
||||
|
||||
it("throws instead of returning an empty array", async () => {
|
||||
await expect(
|
||||
updateReturning(
|
||||
contextThatWritesButCannotReadBack(),
|
||||
{} as never,
|
||||
{},
|
||||
where,
|
||||
),
|
||||
).rejects.toThrow(/wrote 3 row\(s\) but could not read them back/);
|
||||
});
|
||||
|
||||
it("says how to fix it", async () => {
|
||||
await expect(
|
||||
updateReturning(
|
||||
contextThatWritesButCannotReadBack(),
|
||||
{} as never,
|
||||
{},
|
||||
where,
|
||||
),
|
||||
).rejects.toThrow(/filter on a column the update leaves alone/);
|
||||
});
|
||||
|
||||
it("still returns [] when the update genuinely matched nothing", async () => {
|
||||
const { context } = recordingContext("mysql");
|
||||
// recordingContext reports rows for select, so use a zero-write stub.
|
||||
const chain = (result: unknown) => {
|
||||
const t: Record<string, unknown> = {
|
||||
set: () => t,
|
||||
from: () => t,
|
||||
where: () => t,
|
||||
then: (r: (v: unknown) => void) => Promise.resolve(result).then(r),
|
||||
};
|
||||
return t;
|
||||
};
|
||||
const db = {
|
||||
update: () => chain({ affectedRows: 0 }),
|
||||
select: () => chain([]),
|
||||
transaction: (fn: (tx: unknown) => Promise<unknown>) => fn(db),
|
||||
};
|
||||
void context;
|
||||
await expect(
|
||||
updateReturning(
|
||||
{ dialect: "mysql", drizzle: db } as unknown as DatabaseContext,
|
||||
{} as never,
|
||||
{},
|
||||
where,
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteReturning", () => {
|
||||
it("uses a single statement where RETURNING exists", async () => {
|
||||
const { context, calls } = recordingContext("postgres");
|
||||
await deleteReturning(context, {} as never, where);
|
||||
expect(calls).toEqual(["delete"]);
|
||||
});
|
||||
|
||||
it("on mysql, reads first and deletes after", async () => {
|
||||
const { context, calls } = recordingContext("mysql");
|
||||
const rows = await deleteReturning(context, {} as never, where);
|
||||
|
||||
// Reading after the delete would find nothing at all.
|
||||
expect(calls).toEqual(["begin", "select", "delete", "commit"]);
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps both statements in one transaction", async () => {
|
||||
const { context, calls } = recordingContext("mysql");
|
||||
await deleteReturning(context, {} as never, where);
|
||||
|
||||
// Without this, a concurrent write between them makes the returned rows
|
||||
// describe a state that never existed — and with a pool the second
|
||||
// statement need not even reach the same connection.
|
||||
expect(calls[0]).toBe("begin");
|
||||
expect(calls[calls.length - 1]).toBe("commit");
|
||||
});
|
||||
});
|
||||
@@ -17,45 +17,7 @@ describe("RoleRepository", () => {
|
||||
): Promise<RoleRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
permissions TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
granted_by TEXT,
|
||||
granted_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 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
|
||||
VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'user', 'hash', 0, 0);
|
||||
`);
|
||||
|
||||
@@ -17,41 +17,11 @@ describe("SessionRecordingRepository", () => {
|
||||
): Promise<SessionRecordingRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.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 NOT NULL,
|
||||
ip TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE session_recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
access_id INTEGER,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
duration INTEGER,
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
format TEXT NOT NULL DEFAULT 'text',
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT
|
||||
);
|
||||
|
||||
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)
|
||||
VALUES (1, 'user-1', 'one', '10.0.0.1'), (2, 'user-1', 'two', '10.0.0.2'), (3, 'user-2', 'other', '10.0.0.3');
|
||||
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 SessionRecordingRepository(context, onWrite);
|
||||
|
||||
@@ -17,51 +17,11 @@ describe("SessionShareRepository", () => {
|
||||
): Promise<SessionShareRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.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 NOT NULL,
|
||||
ip TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE session_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
tab_instance_id TEXT,
|
||||
share_type TEXT NOT NULL,
|
||||
target_user_id TEXT,
|
||||
link_token TEXT UNIQUE,
|
||||
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
last_joined_at TEXT,
|
||||
join_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE session_share_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
guest_label TEXT,
|
||||
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
left_at TEXT
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('owner-1', 'alice', 'hash'), ('guest-1', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip)
|
||||
VALUES (1, 'owner-1', 'host-one', '10.0.0.1'), (2, 'owner-1', 'host-two', '10.0.0.2');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'owner-1', 'host-one', '10.0.0.1', 22, 'root', 'password'), (2, 'owner-1', 'host-two', '10.0.0.2', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new SessionShareRepository(context, onWrite);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
refreshIntervalSeconds,
|
||||
startSettingsCacheRefresh,
|
||||
stopSettingsCacheRefresh,
|
||||
} from "../../../database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* The settings cache lives in one process and is updated by whichever process
|
||||
* wrote the setting. On SQLite that is the only process there is. On Postgres
|
||||
* and MySQL — the reason those exist here is to let several instances share one
|
||||
* database — a setting changed on one replica would otherwise never reach the
|
||||
* others, because the synchronous read cannot go back to the database.
|
||||
*
|
||||
* Re-priming on a timer does not make settings immediately consistent. It
|
||||
* bounds how long they can disagree.
|
||||
*/
|
||||
describe("settings cache refresh", () => {
|
||||
afterEach(() => stopSettingsCacheRefresh());
|
||||
|
||||
describe("interval", () => {
|
||||
it("defaults to something short enough to matter", () => {
|
||||
expect(refreshIntervalSeconds({})).toBe(30);
|
||||
});
|
||||
|
||||
it("is configurable", () => {
|
||||
expect(
|
||||
refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "5" }),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it("treats zero and nonsense as off", () => {
|
||||
expect(
|
||||
refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "0" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "-1" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "soon" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("re-reads on the interval", async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
startSettingsCacheRefresh(
|
||||
{ SETTINGS_CACHE_REFRESH_SECONDS: "0.01" },
|
||||
refresh,
|
||||
);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(refresh.mock.calls.length).toBeGreaterThan(1),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps running after a refresh throws", async () => {
|
||||
// A transient database blip must not stop the loop, or the replica is stuck
|
||||
// on stale settings until it restarts — the exact failure this prevents.
|
||||
const refresh = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("connection reset"))
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
startSettingsCacheRefresh(
|
||||
{ SETTINGS_CACHE_REFRESH_SECONDS: "0.01" },
|
||||
refresh,
|
||||
);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(refresh.mock.calls.length).toBeGreaterThan(1),
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when switched off", async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
startSettingsCacheRefresh({ SETTINGS_CACHE_REFRESH_SECONDS: "0" }, refresh);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops when told to, and does not stack timers", async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
startSettingsCacheRefresh(
|
||||
{ SETTINGS_CACHE_REFRESH_SECONDS: "0.02" },
|
||||
refresh,
|
||||
);
|
||||
startSettingsCacheRefresh(
|
||||
{ SETTINGS_CACHE_REFRESH_SECONDS: "0.02" },
|
||||
refresh,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 70));
|
||||
stopSettingsCacheRefresh();
|
||||
|
||||
const afterStop = refresh.mock.calls.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(refresh.mock.calls.length).toBe(afterStop);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
forgetCachedSetting,
|
||||
isSettingsCachePrimed,
|
||||
primeSettingsCache,
|
||||
readCachedSetting,
|
||||
resetSettingsCache,
|
||||
updateCachedSetting,
|
||||
} from "../../../database/repositories/settings-cache.js";
|
||||
|
||||
afterEach(() => resetSettingsCache());
|
||||
|
||||
describe("settings cache", () => {
|
||||
it("starts unprimed", () => {
|
||||
expect(isSettingsCachePrimed()).toBe(false);
|
||||
});
|
||||
|
||||
it("reads back what was primed", () => {
|
||||
primeSettingsCache([
|
||||
{ key: "guac_url", value: "guacd:4822" },
|
||||
{ key: "allow_registration", value: "false" },
|
||||
]);
|
||||
|
||||
expect(isSettingsCachePrimed()).toBe(true);
|
||||
expect(readCachedSetting("guac_url")).toBe("guacd:4822");
|
||||
expect(readCachedSetting("allow_registration")).toBe("false");
|
||||
});
|
||||
|
||||
it("returns null for a key that is not set", () => {
|
||||
primeSettingsCache([{ key: "guac_url", value: "guacd:4822" }]);
|
||||
|
||||
expect(readCachedSetting("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null rather than throwing before priming", () => {
|
||||
// Startup ordering means a read can land first. Every caller already
|
||||
// treats null as "use the default", so this must not throw.
|
||||
expect(readCachedSetting("guac_url")).toBeNull();
|
||||
});
|
||||
|
||||
it("reflects a write immediately", () => {
|
||||
primeSettingsCache([{ key: "log_level", value: "info" }]);
|
||||
|
||||
updateCachedSetting("log_level", "debug");
|
||||
|
||||
// A synchronous reader must not see the pre-write value.
|
||||
expect(readCachedSetting("log_level")).toBe("debug");
|
||||
});
|
||||
|
||||
it("accepts a key that did not exist at prime time", () => {
|
||||
primeSettingsCache([]);
|
||||
|
||||
updateCachedSetting("new_key", "value");
|
||||
|
||||
expect(readCachedSetting("new_key")).toBe("value");
|
||||
});
|
||||
|
||||
it("forgets a deleted key", () => {
|
||||
primeSettingsCache([{ key: "guac_url", value: "guacd:4822" }]);
|
||||
|
||||
forgetCachedSetting("guac_url");
|
||||
|
||||
expect(readCachedSetting("guac_url")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores writes while unprimed instead of half-populating", () => {
|
||||
// A partially filled cache would be worse than an empty one: readers
|
||||
// could not tell a real value from a missing prime.
|
||||
updateCachedSetting("guac_url", "guacd:4822");
|
||||
|
||||
expect(isSettingsCachePrimed()).toBe(false);
|
||||
expect(readCachedSetting("guac_url")).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces the previous contents when primed again", () => {
|
||||
primeSettingsCache([{ key: "old", value: "1" }]);
|
||||
primeSettingsCache([{ key: "new", value: "2" }]);
|
||||
|
||||
expect(readCachedSetting("old")).toBeNull();
|
||||
expect(readCachedSetting("new")).toBe("2");
|
||||
});
|
||||
});
|
||||
@@ -15,12 +15,6 @@ describe("SettingsRepository", () => {
|
||||
async function createRepository(): Promise<SettingsRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
return new SettingsRepository(context);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { SharedHostAuthOverrideRepository } from "../../../database/repositories/shared-host-auth-override-repository.js";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
|
||||
describe("SharedHostAuthOverrideRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await adapter?.close();
|
||||
adapter = null;
|
||||
});
|
||||
|
||||
async function createRepository(onWrite?: () => void) {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('owner', 'owner', 'hash'), ('recipient', 'recipient', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, ip, port, username, auth_type)
|
||||
VALUES (42, 'owner', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
|
||||
VALUES (7, 'recipient', 'cred-seven', 'password'),
|
||||
(8, 'recipient', 'cred-eight', 'password');
|
||||
`);
|
||||
|
||||
return {
|
||||
repository: new SharedHostAuthOverrideRepository(context, onWrite),
|
||||
};
|
||||
}
|
||||
|
||||
it("creates, reads, updates and clears overrides by host, user, and protocol", async () => {
|
||||
let writeCount = 0;
|
||||
const { repository } = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.findCredentialId(42, "recipient", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await repository.setCredential(42, "recipient", "ssh", 7);
|
||||
await expect(
|
||||
repository.findCredentialId(42, "recipient", "ssh"),
|
||||
).resolves.toBe(7);
|
||||
|
||||
await repository.setCredential(42, "recipient", "ssh", 8);
|
||||
await repository.setCredential(42, "recipient", "rdp", 7);
|
||||
await expect(
|
||||
repository.findCredentialId(42, "recipient", "ssh"),
|
||||
).resolves.toBe(8);
|
||||
await expect(
|
||||
repository.findCredentialId(42, "recipient", "rdp"),
|
||||
).resolves.toBe(7);
|
||||
|
||||
await expect(
|
||||
repository.clearCredential(42, "recipient", "ssh"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repository.clearCredential(42, "recipient", "ssh"),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
repository.findCredentialId(42, "recipient", "rdp"),
|
||||
).resolves.toBe(7);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
|
||||
it("removes overrides when the host, user, or credential is deleted", async () => {
|
||||
const { repository } = await createRepository();
|
||||
|
||||
await repository.setCredential(42, "recipient", "ssh", 7);
|
||||
await adapter!.run(sql`DELETE FROM ssh_credentials WHERE id = 7`);
|
||||
await expect(
|
||||
repository.findCredentialId(42, "recipient", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await adapter!.run(
|
||||
sql`INSERT INTO ssh_credentials (id, user_id, name, auth_type)
|
||||
VALUES (7, 'recipient', 'cred-seven', 'password')`,
|
||||
);
|
||||
await repository.setCredential(42, "recipient", "ssh", 7);
|
||||
await adapter!.run(sql`DELETE FROM ssh_data WHERE id = 42`);
|
||||
await expect(
|
||||
repository.findCredentialId(42, "recipient", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await adapter!.run(
|
||||
sql`INSERT INTO ssh_data (id, user_id, ip, port, username, auth_type)
|
||||
VALUES (42, 'owner', '10.0.0.1', 22, 'root', 'password')`,
|
||||
);
|
||||
await repository.setCredential(42, "recipient", "ssh", 7);
|
||||
await adapter!.run(sql`DELETE FROM users WHERE id = 'recipient'`);
|
||||
await expect(
|
||||
repository.findCredentialId(42, "recipient", "ssh"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -16,65 +16,25 @@ describe("SharedHostSecretsRepository", () => {
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<{
|
||||
repository: SharedHostSecretsRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
||||
>;
|
||||
}> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite!.exec(`
|
||||
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 'connect',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
override_credential_id INTEGER
|
||||
);
|
||||
|
||||
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,
|
||||
credential_id INTEGER,
|
||||
rdp_credential_id INTEGER,
|
||||
vnc_credential_id INTEGER,
|
||||
telnet_credential_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE shared_host_secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
source_type TEXT NOT NULL DEFAULT 'credential',
|
||||
original_credential_id INTEGER,
|
||||
encrypted_username TEXT,
|
||||
encrypted_auth_type TEXT,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
encrypted_domain TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(host_access_id, target_user_id, protocol)
|
||||
);
|
||||
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id)
|
||||
VALUES
|
||||
(42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124),
|
||||
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL),
|
||||
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('owner-1', 'owner-1', 'hash'),
|
||||
('owner-2', 'owner-2', 'hash');
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'user-1', 'hash'),
|
||||
('user-2', 'user-2', 'hash');
|
||||
INSERT INTO roles (id, name, display_name, is_system) VALUES
|
||||
(7, 'role-7', 'Role 7', 0);
|
||||
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
|
||||
(123, 'user-1', 'cred-123', 'root', 'password'),
|
||||
(124, 'user-1', 'cred-124', 'root', 'password');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id, auth_type)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 'password'),
|
||||
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL, 'password'),
|
||||
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL, 'password');
|
||||
INSERT INTO host_access (id, host_id, user_id, role_id, granted_by)
|
||||
VALUES
|
||||
(1, 42, 'user-1', NULL, 'owner-1'),
|
||||
@@ -84,7 +44,6 @@ describe("SharedHostSecretsRepository", () => {
|
||||
|
||||
return {
|
||||
repository: new SharedHostSecretsRepository(context, onWrite),
|
||||
sqlite: context.sqlite!,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SnippetRepository } from "../../../database/repositories/snippet-repository.js";
|
||||
@@ -14,37 +15,13 @@ describe("SnippetRepository", () => {
|
||||
|
||||
async function createRepository(onWrite?: () => void): Promise<{
|
||||
repository: SnippetRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
||||
>;
|
||||
}> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE snippets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
host_filter TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE snippet_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'user-1', 'hash'),
|
||||
('user-2', 'user-2', 'hash');
|
||||
|
||||
INSERT INTO snippets (
|
||||
id, user_id, name, content, description, folder, "order", host_filter
|
||||
@@ -53,7 +30,6 @@ describe("SnippetRepository", () => {
|
||||
(1, 'user-1', 'root', 'uptime', NULL, NULL, 2, NULL),
|
||||
(2, 'user-1', 'deploy', 'make deploy', 'Deploy app', 'ops', 1, 'linux'),
|
||||
(3, 'user-2', 'other', 'whoami', NULL, NULL, 1, NULL);
|
||||
|
||||
INSERT INTO snippet_folders (id, user_id, name, color, icon)
|
||||
VALUES
|
||||
(1, 'user-1', 'ops', '#123456', 'terminal'),
|
||||
@@ -63,7 +39,6 @@ describe("SnippetRepository", () => {
|
||||
|
||||
return {
|
||||
repository: new SnippetRepository(context, onWrite),
|
||||
sqlite: context.sqlite!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,18 +164,18 @@ describe("SnippetRepository", () => {
|
||||
|
||||
it("deletes all snippets and folders for a user", async () => {
|
||||
const onWrite = vi.fn();
|
||||
const { repository, sqlite } = await createRepository(onWrite);
|
||||
const { repository } = await createRepository(onWrite);
|
||||
|
||||
await expect(repository.deleteByUserId("user-1")).resolves.toEqual({
|
||||
snippetsDeleted: 2,
|
||||
foldersDeleted: 2,
|
||||
});
|
||||
|
||||
expect(sqlite.prepare("SELECT id FROM snippets ORDER BY id").all()).toEqual(
|
||||
[{ id: 3 }],
|
||||
);
|
||||
expect(
|
||||
sqlite.prepare("SELECT id FROM snippet_folders ORDER BY id").all(),
|
||||
await adapter!.query(sql`SELECT id FROM snippets ORDER BY id`),
|
||||
).toEqual([{ id: 3 }]);
|
||||
expect(
|
||||
await adapter!.query(sql`SELECT id FROM snippet_folders ORDER BY id`),
|
||||
).toEqual([{ id: 3 }]);
|
||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatSqlTimestamp,
|
||||
sqlTimestampDaysAgo,
|
||||
} from "../../../database/repositories/sql-timestamp.js";
|
||||
|
||||
describe("sql timestamps", () => {
|
||||
it("matches the CURRENT_TIMESTAMP text format", () => {
|
||||
expect(formatSqlTimestamp(new Date("2026-07-28T01:23:45.678Z"))).toBe(
|
||||
"2026-07-28 01:23:45",
|
||||
);
|
||||
});
|
||||
|
||||
it("subtracts whole days in UTC", () => {
|
||||
const now = new Date("2026-07-28T01:23:45.000Z");
|
||||
|
||||
expect(sqlTimestampDaysAgo(7, now)).toBe("2026-07-21 01:23:45");
|
||||
expect(sqlTimestampDaysAgo(30, now)).toBe("2026-06-28 01:23:45");
|
||||
expect(sqlTimestampDaysAgo(0, now)).toBe("2026-07-28 01:23:45");
|
||||
});
|
||||
|
||||
it("crosses month and year boundaries", () => {
|
||||
expect(sqlTimestampDaysAgo(1, new Date("2026-01-01T00:00:00.000Z"))).toBe(
|
||||
"2025-12-31 00:00:00",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays lexicographically ordered, which is what the cutoff comparison relies on", () => {
|
||||
const now = new Date("2026-07-28T01:23:45.000Z");
|
||||
const older = sqlTimestampDaysAgo(30, now);
|
||||
const newer = sqlTimestampDaysAgo(7, now);
|
||||
|
||||
expect(older < newer).toBe(true);
|
||||
expect(newer < formatSqlTimestamp(now)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -17,41 +17,13 @@ describe("SshCredentialUsageRepository", () => {
|
||||
): Promise<SshCredentialUsageRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
||||
INSERT INTO ssh_credentials (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'cred-one'), (2, 'user-2', 'cred-two');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type)
|
||||
VALUES (1, 'user-1', 'cred-one', 'root', 'password'), (2, 'user-2', 'cred-two', 'root', 'password');
|
||||
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.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new SshCredentialUsageRepository(context, onWrite);
|
||||
|
||||
@@ -4,13 +4,11 @@ import { SsoProviderRepository } from "../../../database/repositories/sso-provid
|
||||
|
||||
describe("SsoProviderRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
let sqlite: Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"];
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
sqlite = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,28 +17,6 @@ describe("SsoProviderRepository", () => {
|
||||
): Promise<SsoProviderRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
sqlite = context.sqlite;
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
sso_provider_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE sso_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
return new SsoProviderRepository(context, onWrite);
|
||||
}
|
||||
@@ -96,7 +72,7 @@ describe("SsoProviderRepository", () => {
|
||||
config: "{}",
|
||||
});
|
||||
|
||||
sqlite?.exec(`
|
||||
await adapter!.exec(`
|
||||
INSERT INTO users (id, username, password_hash, sso_provider_id)
|
||||
VALUES ('user-1', 'u1', 'hash', ${provider.id}),
|
||||
('user-2', 'u2', 'hash', ${provider.id}),
|
||||
|
||||
@@ -17,21 +17,7 @@ describe("SyncTombstoneRepository", () => {
|
||||
): Promise<SyncTombstoneRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sync_tombstones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
sync_id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
@@ -102,24 +88,9 @@ describe("SyncTombstoneRepository", () => {
|
||||
const adapterLocal = new TestSqliteDatabase();
|
||||
adapter = adapterLocal;
|
||||
const context = await adapterLocal.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sync_tombstones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
sync_id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash');
|
||||
|
||||
INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at)
|
||||
VALUES
|
||||
('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'),
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js";
|
||||
import {
|
||||
normalizeSyncTimestamp,
|
||||
timestampAtOrAfter,
|
||||
} from "../../../database/sync-timestamp.js";
|
||||
import { sshCredentials } from "../../../database/db/schema.js";
|
||||
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
// The desktop sync engine always sends its cursor as new Date().toISOString().
|
||||
const ISO_CURSOR = "2026-07-29T09:00:00.000Z";
|
||||
|
||||
describe("sync cursors across timestamp layouts", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The harness migrates the schema itself, so the seeds below are INSERTs into
|
||||
* the real tables. Both `sync_tombstones.user_id` and `ssh_credentials.user_id`
|
||||
* are foreign keys into `users`, which the harness enforces, so the owning row
|
||||
* has to exist before either seed runs.
|
||||
*/
|
||||
async function connect(): Promise<{
|
||||
db: TestSqliteDatabase;
|
||||
context: DatabaseContext;
|
||||
}> {
|
||||
const db = new TestSqliteDatabase();
|
||||
adapter = db;
|
||||
const context = await db.connect();
|
||||
await db.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'user', 'hash');
|
||||
`);
|
||||
return { db, context };
|
||||
}
|
||||
|
||||
it("normalizes both layouts to one comparable form", () => {
|
||||
expect(normalizeSyncTimestamp("2026-07-29T10:11:21.123Z")).toBe(
|
||||
"2026-07-29 10:11:21",
|
||||
);
|
||||
expect(normalizeSyncTimestamp("2026-07-29 10:11:21")).toBe(
|
||||
"2026-07-29 10:11:21",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns tombstones recorded after an ISO cursor, whatever layout they were stored in", async () => {
|
||||
const { db, context } = await connect();
|
||||
await db.exec(`
|
||||
INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at) VALUES
|
||||
('user-1', 'sshCredentials', 'sqlite-layout', '2026-07-29 10:07:32'),
|
||||
('user-1', 'sshCredentials', 'iso-layout', '2026-07-29T10:07:32.500Z'),
|
||||
('user-1', 'sshCredentials', 'too-old', '2026-07-29 08:00:00');
|
||||
`);
|
||||
|
||||
const repo = new SyncTombstoneRepository(context);
|
||||
const rows = await repo.listSince("user-1", "sshCredentials", ISO_CURSOR);
|
||||
|
||||
expect(rows.map((row) => row.syncId).sort()).toEqual([
|
||||
"iso-layout",
|
||||
"sqlite-layout",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns rows written by CURRENT_TIMESTAMP against an ISO cursor", async () => {
|
||||
const { db, context } = await connect();
|
||||
await db.exec(`
|
||||
INSERT INTO ssh_credentials (user_id, name, auth_type, updated_at) VALUES
|
||||
('user-1', 'newer-sqlite-layout', 'password', '2026-07-29 10:11:21'),
|
||||
('user-1', 'newer-iso-layout', 'password', '2026-07-29T10:11:21.123Z'),
|
||||
('user-1', 'older', 'password', '2026-07-29 08:59:59');
|
||||
`);
|
||||
|
||||
const rows = await context.drizzle
|
||||
.select({ name: sshCredentials.name })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, "user-1"),
|
||||
timestampAtOrAfter(sshCredentials.updatedAt, ISO_CURSOR),
|
||||
),
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.name).sort()).toEqual([
|
||||
"newer-iso-layout",
|
||||
"newer-sqlite-layout",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps rows written in the same second as the cursor", async () => {
|
||||
const { db, context } = await connect();
|
||||
await db.exec(`
|
||||
INSERT INTO ssh_credentials (user_id, name, auth_type, updated_at)
|
||||
VALUES ('user-1', 'same-second', 'password', '2026-07-29 09:00:00');
|
||||
`);
|
||||
|
||||
const rows = await context.drizzle
|
||||
.select({ name: sshCredentials.name })
|
||||
.from(sshCredentials)
|
||||
.where(timestampAtOrAfter(sshCredentials.updatedAt, ISO_CURSOR));
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual(["same-second"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, vi } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { DataCrypto } from "../../../utils/data-crypto.js";
|
||||
import { TermixIdentityCaRepository } from "../../../database/repositories/termix-identity-ca-repository.js";
|
||||
@@ -16,45 +17,11 @@ describe("TermixIdentityCaRepository", () => {
|
||||
|
||||
async function createRepository(onWrite = vi.fn()): Promise<{
|
||||
repo: TermixIdentityCaRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
||||
>;
|
||||
onWrite: ReturnType<typeof vi.fn>;
|
||||
}> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE termix_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
handle TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE termix_identity_ca (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
identity_id INTEGER NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
validity_days INTEGER NOT NULL DEFAULT 90,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (identity_id) REFERENCES termix_identities(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash');
|
||||
INSERT INTO termix_identities (id, user_id, handle)
|
||||
@@ -63,7 +30,6 @@ describe("TermixIdentityCaRepository", () => {
|
||||
|
||||
return {
|
||||
repo: new TermixIdentityCaRepository(context, onWrite),
|
||||
sqlite: context.sqlite!,
|
||||
onWrite,
|
||||
};
|
||||
}
|
||||
@@ -95,7 +61,7 @@ describe("TermixIdentityCaRepository", () => {
|
||||
}
|
||||
|
||||
it("creates CA private keys with the real row id before encryption", async () => {
|
||||
const { repo, sqlite, onWrite } = await createRepository();
|
||||
const { repo, onWrite } = await createRepository();
|
||||
mockCrypto();
|
||||
|
||||
const created = await repo.createEncryptedForUser("user-1", {
|
||||
@@ -106,16 +72,14 @@ describe("TermixIdentityCaRepository", () => {
|
||||
validityDays: 120,
|
||||
});
|
||||
|
||||
const raw = sqlite
|
||||
.prepare(
|
||||
"SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?",
|
||||
)
|
||||
.get(7) as {
|
||||
const [raw] = (await adapter!.query(
|
||||
sql`SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`,
|
||||
)) as {
|
||||
id: number;
|
||||
public_key: string;
|
||||
private_key: string;
|
||||
validity_days: number;
|
||||
};
|
||||
}[];
|
||||
|
||||
expect(created.privateKey).toBe("decrypted-ca-private");
|
||||
expect(raw.private_key).toBe("encrypted-ca-private");
|
||||
@@ -131,13 +95,12 @@ describe("TermixIdentityCaRepository", () => {
|
||||
});
|
||||
|
||||
it("reads public CA metadata without decrypting private key material", async () => {
|
||||
const { repo, sqlite } = await createRepository();
|
||||
const { repo } = await createRepository();
|
||||
const decryptSpy = vi.spyOn(DataCrypto, "decryptRecord");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45);
|
||||
await adapter!.run(
|
||||
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days)
|
||||
VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
|
||||
);
|
||||
|
||||
await expect(repo.findPublicByIdentityId(7)).resolves.toEqual({
|
||||
publicKey: "ssh-ed25519 public",
|
||||
@@ -147,13 +110,12 @@ describe("TermixIdentityCaRepository", () => {
|
||||
});
|
||||
|
||||
it("decrypts CA private keys through the user data boundary", async () => {
|
||||
const { repo, sqlite } = await createRepository();
|
||||
const { repo } = await createRepository();
|
||||
mockCrypto();
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45);
|
||||
await adapter!.run(
|
||||
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days)
|
||||
VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
|
||||
);
|
||||
|
||||
const ca = await repo.findDecryptedByIdentityId("user-1", 7);
|
||||
|
||||
@@ -172,13 +134,11 @@ describe("TermixIdentityCaRepository", () => {
|
||||
});
|
||||
|
||||
it("updates CA private keys through encrypted writes", async () => {
|
||||
const { repo, sqlite, onWrite } = await createRepository();
|
||||
const { repo, onWrite } = await createRepository();
|
||||
mockCrypto();
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(7, "user-1", "ssh-ed25519 old", "encrypted-ca-private", 45);
|
||||
await adapter!.run(
|
||||
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 old', 'encrypted-ca-private', 45)`,
|
||||
);
|
||||
onWrite.mockClear();
|
||||
|
||||
const updated = await repo.updateEncryptedForIdentity("user-1", 7, {
|
||||
@@ -187,15 +147,13 @@ describe("TermixIdentityCaRepository", () => {
|
||||
validityDays: 90,
|
||||
});
|
||||
|
||||
const raw = sqlite
|
||||
.prepare(
|
||||
"SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?",
|
||||
)
|
||||
.get(7) as {
|
||||
const [raw] = (await adapter!.query(
|
||||
sql`SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`,
|
||||
)) as {
|
||||
public_key: string;
|
||||
private_key: string;
|
||||
validity_days: number;
|
||||
};
|
||||
}[];
|
||||
|
||||
expect(updated).toMatchObject({
|
||||
publicKey: "ssh-ed25519 new",
|
||||
@@ -219,55 +177,47 @@ describe("TermixIdentityCaRepository", () => {
|
||||
});
|
||||
|
||||
it("deletes CA rows through the write boundary", async () => {
|
||||
const { repo, sqlite, onWrite } = await createRepository();
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45);
|
||||
const { repo, onWrite } = await createRepository();
|
||||
await adapter!.run(
|
||||
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
|
||||
);
|
||||
onWrite.mockClear();
|
||||
|
||||
await expect(repo.deleteByIdentityId(7)).resolves.toBe(true);
|
||||
await expect(repo.deleteByIdentityId(7)).resolves.toBe(false);
|
||||
expect(
|
||||
sqlite.prepare("SELECT COUNT(*) AS count FROM termix_identity_ca").get(),
|
||||
).toEqual({ count: 0 });
|
||||
(
|
||||
await adapter!.query(
|
||||
sql`SELECT COUNT(*) AS count FROM termix_identity_ca`,
|
||||
)
|
||||
).map((row) => Number((row as { count: unknown }).count)),
|
||||
).toEqual([0]);
|
||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deletes CA rows for a user", async () => {
|
||||
const { repo, sqlite, onWrite } = await createRepository();
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("user-2", "bob", "hash");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO termix_identities (id, user_id, handle) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(8, "user-2", "bob");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(8, "user-2", "ssh-ed25519 other", "encrypted-other", 90);
|
||||
const { repo, onWrite } = await createRepository();
|
||||
await adapter!.run(
|
||||
sql`INSERT INTO users (id, username, password_hash) VALUES ('user-2', 'bob', 'hash')`,
|
||||
);
|
||||
await adapter!.run(
|
||||
sql`INSERT INTO termix_identities (id, user_id, handle) VALUES (8, 'user-2', 'bob')`,
|
||||
);
|
||||
await adapter!.run(
|
||||
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`,
|
||||
);
|
||||
await adapter!.run(
|
||||
sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (8, 'user-2', 'ssh-ed25519 other', 'encrypted-other', 90)`,
|
||||
);
|
||||
onWrite.mockClear();
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, public_key FROM termix_identity_ca ORDER BY user_id",
|
||||
)
|
||||
.all(),
|
||||
await adapter!.query(
|
||||
sql`SELECT user_id, public_key FROM termix_identity_ca ORDER BY user_id`,
|
||||
),
|
||||
).toEqual([{ user_id: "user-2", public_key: "ssh-ed25519 other" }]);
|
||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -18,44 +18,12 @@ describe("TermixIdentityRepository", () => {
|
||||
}> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE termix_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
handle TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE termix_identity_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
identity_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
key_type TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL,
|
||||
label TEXT,
|
||||
comment TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
credential_id INTEGER,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (identity_id) REFERENCES termix_identities(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
|
||||
(10, 'user-1', 'cred-10', 'root', 'password'),
|
||||
(20, 'user-1', 'cred-20', 'root', 'password');
|
||||
`);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,31 +1,452 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import {
|
||||
getTableColumns,
|
||||
getTableName,
|
||||
is,
|
||||
sql,
|
||||
Table,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import * as schema from "../../../database/db/schema.js";
|
||||
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
|
||||
import type { DatabaseDialect } from "../../../database/db/dialect.js";
|
||||
|
||||
/**
|
||||
* Which engine the repository tests run against.
|
||||
*
|
||||
* Defaults to SQLite, so `npm test` behaves as it always has and needs no
|
||||
* server. Set TEST_DIALECT=postgres or mysql, plus TEST_DATABASE_URL, to run
|
||||
* the same tests against a real one — see the database-dialects CI job.
|
||||
*/
|
||||
export function testDialect(env = process.env): DatabaseDialect {
|
||||
const value = env.TEST_DIALECT?.trim().toLowerCase();
|
||||
if (value === "postgres" || value === "mysql") return value;
|
||||
return "sqlite";
|
||||
}
|
||||
|
||||
/** Every table drizzle knows about, for wiping between tests. */
|
||||
function allTableNames(): string[] {
|
||||
return Object.values(schema)
|
||||
.filter((value) => is(value, Table))
|
||||
.map((table) => getTableName(table as Table));
|
||||
}
|
||||
|
||||
export class TestSqliteDatabase {
|
||||
private sqlite: Database.Database | null = null;
|
||||
private context: DatabaseContext | null = null;
|
||||
private readonly dialect: DatabaseDialect;
|
||||
|
||||
constructor(dialect: DatabaseDialect = testDialect()) {
|
||||
this.dialect = dialect;
|
||||
}
|
||||
|
||||
async connect(): Promise<DatabaseContext> {
|
||||
if (this.context) return this.context;
|
||||
|
||||
if (this.dialect !== "sqlite") {
|
||||
this.context = await this.connectRemote();
|
||||
return this.context;
|
||||
}
|
||||
|
||||
this.sqlite = new Database(":memory:");
|
||||
this.sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
this.sqlite.exec(sqliteSchemaSql());
|
||||
this.context = {
|
||||
dialect: "sqlite",
|
||||
drizzle: drizzle(this.sqlite, { schema }),
|
||||
sqlite: this.sqlite,
|
||||
};
|
||||
|
||||
return this.context;
|
||||
}
|
||||
|
||||
private async connectRemote(): Promise<DatabaseContext> {
|
||||
const url = process.env.TEST_DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
`TEST_DIALECT=${this.dialect} requires TEST_DATABASE_URL to be set.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { drizzle: connect } = await import(
|
||||
this.dialect === "postgres"
|
||||
? "drizzle-orm/node-postgres"
|
||||
: "drizzle-orm/mysql2"
|
||||
);
|
||||
const db = connect(url) as unknown as DatabaseContext["drizzle"];
|
||||
const context: DatabaseContext = { dialect: this.dialect, drizzle: db };
|
||||
|
||||
await migrateOnce(this.dialect, db);
|
||||
await truncateAll(context);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs seed SQL. Synchronous on SQLite, which is what the tests were written
|
||||
* against; on the other engines it returns a promise the caller must await.
|
||||
*
|
||||
* The seeds are plain INSERTs, portable apart from identifier quoting, which
|
||||
* `portableSql` fixes up.
|
||||
*/
|
||||
exec(statements: string): void | Promise<void> {
|
||||
if (this.sqlite) {
|
||||
this.sqlite.exec(statements);
|
||||
return;
|
||||
}
|
||||
const context = this.context;
|
||||
if (!context) throw new Error("connect() must be called before exec()");
|
||||
|
||||
return (async () => {
|
||||
const touched = new Set<string>();
|
||||
for (const statement of splitStatements(statements)) {
|
||||
await runSql(context, sql.raw(portableSql(statement, context.dialect)));
|
||||
const table = /INSERT INTO\s+([a-z_]+)/i.exec(statement)?.[1];
|
||||
if (table) touched.add(table);
|
||||
}
|
||||
await resyncAutoIncrement(context, touched);
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Portable read for assertions. Build the statement with drizzle's `sql`
|
||||
* template so placeholders and quoting come out right on each engine.
|
||||
*/
|
||||
async query<T = Record<string, unknown>>(statement: SQL): Promise<T[]> {
|
||||
if (!this.context)
|
||||
throw new Error("connect() must be called before query()");
|
||||
return runSql<T>(this.context, statement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Portable write for test setup.
|
||||
*
|
||||
* Separate from query() because better-sqlite3 refuses `.all()` on a
|
||||
* statement that returns no rows — "This statement does not return data".
|
||||
*/
|
||||
async run(statement: SQL): Promise<void> {
|
||||
const context = this.context;
|
||||
if (!context) throw new Error("connect() must be called before run()");
|
||||
|
||||
if (this.sqlite) {
|
||||
(context.drizzle as unknown as { run: (s: SQL) => unknown }).run(
|
||||
statement,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await runSql(context, statement);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.sqlite) {
|
||||
this.sqlite.close();
|
||||
this.sqlite = null;
|
||||
this.context = null;
|
||||
}
|
||||
this.context = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSql<T>(
|
||||
context: DatabaseContext,
|
||||
statement: SQL,
|
||||
): Promise<T[]> {
|
||||
const db = context.drizzle as unknown as {
|
||||
all?: (s: SQL) => Promise<T[]> | T[];
|
||||
execute?: (s: SQL) => Promise<unknown>;
|
||||
};
|
||||
|
||||
if (context.dialect === "sqlite" && db.all) {
|
||||
return (await db.all(statement)) as T[];
|
||||
}
|
||||
|
||||
const result = (await db.execute!(statement)) as
|
||||
{ rows?: T[] } | T[] | undefined;
|
||||
|
||||
// mysql2 answers [rows, fields]; node-postgres answers { rows }.
|
||||
if (Array.isArray(result)) {
|
||||
return (Array.isArray(result[0]) ? result[0] : result) as T[];
|
||||
}
|
||||
return (result?.rows ?? []) as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Empties every table between tests on the client-server engines, where the
|
||||
* database outlives the process and cannot be thrown away like an in-memory
|
||||
* SQLite one.
|
||||
*/
|
||||
async function truncateAll(context: DatabaseContext): Promise<void> {
|
||||
const tables = allTableNames();
|
||||
|
||||
if (context.dialect === "postgres") {
|
||||
const list = tables.map((t) => `"${t}"`).join(", ");
|
||||
await runSql(
|
||||
context,
|
||||
sql.raw(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Truncating all 53 tables takes ~2s on MySQL, which every test would pay.
|
||||
// Ask which ones actually hold rows first: after the first test only a
|
||||
// handful do, and the check is a single query.
|
||||
// Each branch is parenthesised: LIMIT binds to the whole UNION otherwise.
|
||||
const counts = tables
|
||||
.map((t) => `(SELECT '${t}' AS name FROM \`${t}\` LIMIT 1)`)
|
||||
.join(" UNION ALL ");
|
||||
const occupied = await runSql<{ name: string }>(context, sql.raw(counts));
|
||||
if (occupied.length === 0) return;
|
||||
|
||||
await runSql(context, sql.raw("SET FOREIGN_KEY_CHECKS = 0"));
|
||||
for (const { name } of occupied) {
|
||||
await runSql(context, sql.raw(`TRUNCATE TABLE \`${name}\``));
|
||||
}
|
||||
await runSql(context, sql.raw("SET FOREIGN_KEY_CHECKS = 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits seed SQL into statements, ignoring semicolons inside string literals —
|
||||
* JSON payloads in the fixtures contain them.
|
||||
*/
|
||||
function splitStatements(sql: string): string[] {
|
||||
const out: string[] = [];
|
||||
let current = "";
|
||||
let inString = false;
|
||||
|
||||
for (let i = 0; i < sql.length; i++) {
|
||||
const ch = sql[i];
|
||||
if (ch === "'") {
|
||||
// '' is an escaped quote inside a string, not a delimiter.
|
||||
if (inString && sql[i + 1] === "'") {
|
||||
current += "''";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
inString = !inString;
|
||||
}
|
||||
if (ch === ";" && !inString) {
|
||||
if (current.trim()) out.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current.trim()) out.push(current.trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
let cachedBooleanColumns: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* Columns the schema declares as booleans, by table.column.
|
||||
*
|
||||
* Read from drizzle rather than listed here, so a new boolean column needs no
|
||||
* change in this file.
|
||||
*/
|
||||
function booleanColumns(): Set<string> {
|
||||
if (cachedBooleanColumns) return cachedBooleanColumns;
|
||||
|
||||
const found = new Set<string>();
|
||||
for (const value of Object.values(schema)) {
|
||||
if (!is(value, Table)) continue;
|
||||
const table = getTableName(value as Table);
|
||||
for (const column of Object.values(getTableColumns(value as Table))) {
|
||||
if (column.dataType === "boolean") found.add(`${table}.${column.name}`);
|
||||
}
|
||||
}
|
||||
cachedBooleanColumns = found;
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds are written in SQLite's dialect. Two things do not carry:
|
||||
*
|
||||
* - a reserved word used as a column name is `"order"` on SQLite and Postgres,
|
||||
* `` `order` `` on MySQL
|
||||
* - SQLite stores booleans as 0/1, and writing an integer into a native boolean
|
||||
* column is an error on Postgres. Every engine understands the TRUE/FALSE
|
||||
* keywords, so boolean columns are rewritten to those.
|
||||
*/
|
||||
function portableSql(statement: string, dialect: DatabaseDialect): string {
|
||||
const out = rewriteBooleanLiterals(statement);
|
||||
if (dialect !== "mysql") return out;
|
||||
|
||||
// Only the column list, before VALUES. A blanket replace also mangles the
|
||||
// double quotes inside JSON payloads in the values — '{"slots":[]}' became
|
||||
// '{`slots`:[]}', which is valid SQL and silently wrong data.
|
||||
const split = /^(.*?\bVALUES\b)(.*)$/is.exec(out);
|
||||
if (!split) return out.replace(/"([a-z_]+)"/g, "`$1`");
|
||||
return split[1].replace(/"([a-z_]+)"/g, "`$1`") + split[2];
|
||||
}
|
||||
|
||||
/** Rewrites 0/1 to FALSE/TRUE in the value positions of boolean columns. */
|
||||
function rewriteBooleanLiterals(statement: string): string {
|
||||
const booleans = booleanColumns();
|
||||
|
||||
return statement.replace(
|
||||
/INSERT INTO\s+([a-z_]+)\s*\(([^)]*)\)\s*VALUES\s*((?:\([^()]*\)\s*,?\s*)+)/gis,
|
||||
(whole, table: string, cols: string, values: string) => {
|
||||
const names = cols.split(",").map((c) => c.trim().replace(/["`]/g, ""));
|
||||
const flags = names.map((n) => booleans.has(`${table}.${n}`));
|
||||
if (!flags.some(Boolean)) return whole;
|
||||
|
||||
const rewritten = values.replace(
|
||||
/\(([^()]*)\)/g,
|
||||
(row, inner: string) => {
|
||||
const parts = splitValues(inner);
|
||||
return `(${parts
|
||||
.map((v, i) =>
|
||||
flags[i] && /^[01]$/.test(v.trim())
|
||||
? v.trim() === "1"
|
||||
? "TRUE"
|
||||
: "FALSE"
|
||||
: v,
|
||||
)
|
||||
.join(",")})`;
|
||||
},
|
||||
);
|
||||
return `INSERT INTO ${table} (${cols}) VALUES ${rewritten}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Splits a VALUES row on commas that are not inside a string literal. */
|
||||
function splitValues(row: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let inString = false;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const ch = row[i];
|
||||
if (ch === "'") {
|
||||
if (inString && row[i + 1] === "'") {
|
||||
current += "''";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
inString = !inString;
|
||||
}
|
||||
if (ch === "," && !inString) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves each table's id generator past the ids the seed inserted by hand.
|
||||
*
|
||||
* SQLite picks `max(id) + 1` when a row omits the key, so a fixture that writes
|
||||
* `id = 1, 2, 3` and then lets the repository insert one more just works. A
|
||||
* Postgres sequence or a MySQL auto_increment counter does not know about rows
|
||||
* inserted with an explicit id, so it hands out 1 again and the insert collides
|
||||
* with the fixture's own data.
|
||||
*/
|
||||
async function resyncAutoIncrement(
|
||||
context: DatabaseContext,
|
||||
tables: Set<string>,
|
||||
): 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.
|
||||
if (context.dialect === "postgres") {
|
||||
const [seq] = await runSql<{ name: string | null }>(
|
||||
context,
|
||||
sql.raw(`SELECT pg_get_serial_sequence('${table}', 'id') AS name`),
|
||||
);
|
||||
if (!seq?.name) continue;
|
||||
|
||||
await runSql(
|
||||
context,
|
||||
sql.raw(
|
||||
`SELECT setval('${seq.name}', ` +
|
||||
`COALESCE((SELECT MAX(id) FROM "${table}"), 0) + 1, false)`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [column] = await runSql<{ extra: string }>(
|
||||
context,
|
||||
sql.raw(
|
||||
`SELECT EXTRA AS extra FROM information_schema.columns ` +
|
||||
`WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '${table}' ` +
|
||||
`AND COLUMN_NAME = 'id'`,
|
||||
),
|
||||
);
|
||||
if (!column?.extra?.includes("auto_increment")) continue;
|
||||
|
||||
const [row] = await runSql<{ next: number | null }>(
|
||||
context,
|
||||
sql.raw(`SELECT MAX(id) + 1 AS next FROM \`${table}\``),
|
||||
);
|
||||
if (row?.next) {
|
||||
await runSql(
|
||||
context,
|
||||
sql.raw(`ALTER TABLE \`${table}\` AUTO_INCREMENT = ${row.next}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrations run once per worker, not once per fixture.
|
||||
*
|
||||
* Every test builds a fixture, and each would otherwise re-run the migrator
|
||||
* against the same shared database. drizzle's journal makes that a no-op only
|
||||
* when the first run finished — several fixtures racing inside one file hit
|
||||
* "table already exists" instead.
|
||||
*/
|
||||
const migrations = new Map<string, Promise<void>>();
|
||||
|
||||
function migrateOnce(
|
||||
dialect: DatabaseDialect,
|
||||
db: DatabaseContext["drizzle"],
|
||||
): Promise<void> {
|
||||
const key = `${dialect}:${process.env.TEST_DATABASE_URL}`;
|
||||
let running = migrations.get(key);
|
||||
if (!running) {
|
||||
running = (async () => {
|
||||
const { runRemoteMigrations } =
|
||||
await import("../../../database/db/migrate.js");
|
||||
await runRemoteMigrations(dialect, db);
|
||||
})();
|
||||
migrations.set(key, running);
|
||||
}
|
||||
return running;
|
||||
}
|
||||
|
||||
let cachedSqliteSchema: string | null = null;
|
||||
|
||||
/**
|
||||
* The full schema, from the generated SQLite migration 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.
|
||||
*/
|
||||
function sqliteSchemaSql(): string {
|
||||
if (cachedSqliteSchema) return cachedSqliteSchema;
|
||||
|
||||
const dir = path.resolve(process.cwd(), "drizzle", "sqlite");
|
||||
const file = fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort()
|
||||
.at(-1);
|
||||
|
||||
if (!file) throw new Error(`No SQLite migration found in ${dir}`);
|
||||
|
||||
cachedSqliteSchema = fs
|
||||
.readFileSync(path.join(dir, file), "utf8")
|
||||
.split("--> statement-breakpoint")
|
||||
.join("\n");
|
||||
|
||||
return cachedSqliteSchema;
|
||||
}
|
||||
|
||||
@@ -17,32 +17,11 @@ describe("TmuxSessionTagRepository", () => {
|
||||
): Promise<TmuxSessionTagRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE tmux_session_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two');
|
||||
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-2', 'two', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO tmux_session_tags (user_id, host_id, session_name, tag)
|
||||
VALUES
|
||||
('user-1', 1, 'api', 'prod'),
|
||||
|
||||
@@ -17,33 +17,11 @@ describe("TransferRecentRepository", () => {
|
||||
): Promise<TransferRecentRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE transfer_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
source_host_id INTEGER NOT NULL,
|
||||
dest_host_id INTEGER NOT NULL,
|
||||
dest_path TEXT NOT NULL,
|
||||
dest_path_label TEXT NOT NULL,
|
||||
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'source'), (2, 'user-1', 'dest-a'), (3, 'user-1', 'dest-b'), (4, 'user-2', 'other');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'source', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'dest-a', '10.0.0.1', 22, 'root', 'password'), (3, 'user-1', 'dest-b', '10.0.0.1', 22, 'root', 'password'), (4, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new TransferRecentRepository(context, onWrite);
|
||||
|
||||
@@ -17,27 +17,7 @@ describe("TrustedDeviceRepository", () => {
|
||||
}> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE trusted_devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
device_fingerprint TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
device_info TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'admin', 'hash'),
|
||||
('user-2', 'user', 'hash');
|
||||
|
||||
@@ -15,146 +15,17 @@ describe("UserDataExportRepository", () => {
|
||||
async function createRepository(): Promise<UserDataExportRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.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,
|
||||
connection_type TEXT NOT NULL DEFAULT 'ssh',
|
||||
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,
|
||||
use_warpgate INTEGER NOT NULL DEFAULT 0,
|
||||
force_keyboard_interactive TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
sudo_password TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
credential_id INTEGER,
|
||||
override_credential_username INTEGER,
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
jump_hosts TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
scp_legacy INTEGER NOT NULL DEFAULT 0,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
enable_tmux_monitor INTEGER NOT NULL DEFAULT 0,
|
||||
show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
||||
show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
enable_proxmox INTEGER NOT NULL DEFAULT 0,
|
||||
proxmox_config TEXT,
|
||||
terminal_config TEXT,
|
||||
quick_actions TEXT,
|
||||
notes TEXT,
|
||||
enable_ssh INTEGER NOT NULL DEFAULT 1,
|
||||
enable_rdp INTEGER NOT NULL DEFAULT 0,
|
||||
enable_vnc INTEGER NOT NULL DEFAULT 0,
|
||||
enable_telnet INTEGER NOT NULL DEFAULT 0,
|
||||
ssh_port INTEGER DEFAULT 22,
|
||||
rdp_port INTEGER DEFAULT 3389,
|
||||
vnc_port INTEGER DEFAULT 5900,
|
||||
telnet_port INTEGER DEFAULT 23,
|
||||
rdp_credential_id INTEGER,
|
||||
rdp_user TEXT,
|
||||
rdp_password TEXT,
|
||||
rdp_domain TEXT,
|
||||
rdp_security TEXT,
|
||||
rdp_ignore_cert INTEGER DEFAULT 0,
|
||||
vnc_credential_id INTEGER,
|
||||
vnc_password TEXT,
|
||||
vnc_user TEXT,
|
||||
telnet_user TEXT,
|
||||
telnet_password TEXT,
|
||||
telnet_credential_id INTEGER,
|
||||
rdp_auth_type TEXT,
|
||||
vnc_auth_type TEXT,
|
||||
telnet_auth_type TEXT,
|
||||
domain TEXT,
|
||||
security TEXT,
|
||||
ignore_cert INTEGER DEFAULT 0,
|
||||
guacamole_config TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
socks5_port INTEGER,
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
socks5_proxy_chain TEXT,
|
||||
mac_address TEXT,
|
||||
wol_broadcast_address TEXT,
|
||||
port_knock_sequence TEXT,
|
||||
host_key_fingerprint TEXT,
|
||||
host_key_type TEXT,
|
||||
host_key_algorithm TEXT DEFAULT 'sha256',
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
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,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
private_key TEXT,
|
||||
public_key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
detected_key_type TEXT,
|
||||
cert_public_key TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
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', 'web', '10.0.0.1', 22, 'root', 'password'),
|
||||
(2, 'user-2', 'db', '10.0.0.2', 22, 'root', 'password');
|
||||
|
||||
INSERT INTO ssh_credentials (id, user_id, name, auth_type, username, password)
|
||||
VALUES
|
||||
(1, 'user-1', 'prod', 'password', 'root', 'secret'),
|
||||
(2, 'user-2', 'other', 'password', 'root', 'secret');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES
|
||||
(1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password'),
|
||||
(2, 'user-2', 'db', '10.0.0.2', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new UserDataExportRepository(context);
|
||||
|
||||
@@ -17,41 +17,7 @@ describe("UserPreferenceRepository", () => {
|
||||
): Promise<UserPreferenceRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
reopen_tabs_on_login INTEGER NOT NULL DEFAULT 0,
|
||||
theme TEXT,
|
||||
font_size TEXT,
|
||||
accent_color TEXT,
|
||||
language TEXT,
|
||||
storage_mode TEXT,
|
||||
command_autocomplete INTEGER,
|
||||
command_palette_enabled INTEGER,
|
||||
show_host_tags INTEGER,
|
||||
host_tray_on_click INTEGER,
|
||||
pin_app_rail INTEGER,
|
||||
expand_app_rail_on_hover INTEGER,
|
||||
folders_collapsed INTEGER,
|
||||
confirm_snippet_execution INTEGER,
|
||||
disable_update_check INTEGER,
|
||||
confirm_tab_close INTEGER,
|
||||
hidden_rail_tabs TEXT,
|
||||
compact_host_view INTEGER,
|
||||
status_color_scheme TEXT,
|
||||
custom_themes TEXT,
|
||||
custom_keybindings TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash');
|
||||
`);
|
||||
|
||||
@@ -35,45 +35,6 @@ describe("UserRepository and SessionRepository", () => {
|
||||
}> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
oidc_identifier TEXT,
|
||||
sso_provider_id INTEGER,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
issuer_url TEXT,
|
||||
authorization_url TEXT,
|
||||
token_url TEXT,
|
||||
identifier_path TEXT,
|
||||
name_path TEXT,
|
||||
scopes TEXT DEFAULT 'openid email profile',
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
totp_backup_codes TEXT,
|
||||
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
jwt_token TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
device_info TEXT NOT NULL,
|
||||
oidc_sub TEXT,
|
||||
oidc_sid TEXT,
|
||||
sso_provider_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
return {
|
||||
users: new UserRepository(context, options.onUserWrite),
|
||||
|
||||
@@ -17,41 +17,13 @@ describe("VaultProfileRepository", () => {
|
||||
): Promise<VaultProfileRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE vault_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
vault_addr TEXT NOT NULL,
|
||||
vault_namespace TEXT,
|
||||
oidc_mount TEXT,
|
||||
oidc_role TEXT,
|
||||
ssh_mount TEXT,
|
||||
ssh_role TEXT NOT NULL,
|
||||
valid_principals TEXT,
|
||||
key_type TEXT,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO vault_profiles (
|
||||
id, user_id, name, vault_addr, ssh_role, shared, updated_at
|
||||
)
|
||||
VALUES
|
||||
(1, 'user-1', 'owned', 'https://vault.one', 'role-one', 0, '2026-01-01T00:00:00.000Z'),
|
||||
VALUES (1, 'user-1', 'owned', 'https://vault.one', 'role-one', 0, '2026-01-01T00:00:00.000Z'),
|
||||
(2, 'user-2', 'shared', 'https://vault.two', 'role-two', 1, '2026-01-02T00:00:00.000Z'),
|
||||
(3, 'user-2', 'hidden', 'https://vault.three', 'role-three', 0, '2026-01-03T00:00:00.000Z');
|
||||
`);
|
||||
|
||||
@@ -17,35 +17,11 @@ describe("VaultTokenRepository", () => {
|
||||
): Promise<VaultTokenRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE vault_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE vault_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
ssh_cert TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used TEXT,
|
||||
UNIQUE(user_id, profile_id)
|
||||
);
|
||||
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO vault_profiles (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two');
|
||||
INSERT INTO vault_profiles (id, user_id, name, vault_addr, ssh_role)
|
||||
VALUES (1, 'user-1', 'one', 'http://vault', 'r'), (2, 'user-2', 'two', 'http://vault', 'r');
|
||||
INSERT INTO vault_tokens (
|
||||
user_id, profile_id, ssh_cert, private_key, expires_at
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
containsOwnerPrivateAuthUpdate,
|
||||
isNonEmptyString,
|
||||
isOptionalBoolean,
|
||||
isValidPort,
|
||||
normalizeImportedHost,
|
||||
renameFolderPath,
|
||||
@@ -9,6 +11,45 @@ import {
|
||||
transformHostResponse,
|
||||
} from "../../../database/routes/host-normalizers.js";
|
||||
|
||||
describe("containsOwnerPrivateAuthUpdate", () => {
|
||||
it("detects owner-only SSH auth fields, including explicit clears", () => {
|
||||
expect(containsOwnerPrivateAuthUpdate({ password: null }, "ssh")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
containsOwnerPrivateAuthUpdate({ credentialId: undefined }, "ssh"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
containsOwnerPrivateAuthUpdate({ authType: "password" }, "ssh"),
|
||||
).toBe(true);
|
||||
expect(containsOwnerPrivateAuthUpdate({ shareSshAuth: true }, "ssh")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps protocol field definitions isolated", () => {
|
||||
expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "rdp")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "ssh")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows shared editors to update non-authentication host settings", () => {
|
||||
expect(
|
||||
containsOwnerPrivateAuthUpdate(
|
||||
{
|
||||
name: "renamed",
|
||||
ip: "10.0.0.5",
|
||||
notes: "updated",
|
||||
},
|
||||
"ssh",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNonEmptyString", () => {
|
||||
it("accepts non-blank strings", () => {
|
||||
expect(isNonEmptyString("hello")).toBe(true);
|
||||
@@ -24,6 +65,21 @@ describe("isNonEmptyString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOptionalBoolean", () => {
|
||||
it("accepts booleans and an omitted value", () => {
|
||||
expect(isOptionalBoolean(true)).toBe(true);
|
||||
expect(isOptionalBoolean(false)).toBe(true);
|
||||
expect(isOptionalBoolean(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects truthy string and numeric lookalikes", () => {
|
||||
expect(isOptionalBoolean("false")).toBe(false);
|
||||
expect(isOptionalBoolean("0")).toBe(false);
|
||||
expect(isOptionalBoolean(1)).toBe(false);
|
||||
expect(isOptionalBoolean(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renameFolderPath", () => {
|
||||
it("renames an exact folder match", () => {
|
||||
expect(renameFolderPath("Production", "Production", "Prod")).toBe("Prod");
|
||||
@@ -143,11 +199,16 @@ describe("stripSensitiveFields", () => {
|
||||
key: "PRIVATE KEY",
|
||||
keyPassword: "kp",
|
||||
sudoPassword: "sp",
|
||||
terminalConfig: {
|
||||
theme: "termix",
|
||||
sudoPassword: "nested-sudo",
|
||||
},
|
||||
});
|
||||
expect(result.password).toBeUndefined();
|
||||
expect(result.key).toBeUndefined();
|
||||
expect(result.keyPassword).toBeUndefined();
|
||||
expect(result.sudoPassword).toBeUndefined();
|
||||
expect(result.terminalConfig).toEqual({ theme: "termix" });
|
||||
expect(result.hasPassword).toBe(true);
|
||||
expect(result.hasKey).toBe(true);
|
||||
expect(result.hasKeyPassword).toBe(true);
|
||||
@@ -190,11 +251,13 @@ describe("transformHostResponse", () => {
|
||||
tags: "a,b,c",
|
||||
enableTerminal: 1,
|
||||
enableTunnel: 0,
|
||||
shareSshAuth: 1,
|
||||
pin: 1,
|
||||
});
|
||||
expect(result.tags).toEqual(["a", "b", "c"]);
|
||||
expect(result.enableTerminal).toBe(true);
|
||||
expect(result.enableTunnel).toBe(false);
|
||||
expect(result.shareSshAuth).toBe(true);
|
||||
expect(result.pin).toBe(true);
|
||||
});
|
||||
|
||||
@@ -258,6 +321,9 @@ describe("sanitizeHostForRecipient", () => {
|
||||
tags: ["linux"],
|
||||
notes: "secret runbook",
|
||||
quickActions: [{ name: "restart", snippetId: "1" }],
|
||||
credentialId: 7,
|
||||
shareSshAuth: true,
|
||||
overrideCredentialUsername: true,
|
||||
password: "hunter2",
|
||||
key: "PRIVATE",
|
||||
sudoPassword: "sudo",
|
||||
@@ -268,6 +334,11 @@ describe("sanitizeHostForRecipient", () => {
|
||||
sshPort: 22,
|
||||
rdpPort: 3389,
|
||||
defaultPath: "/srv",
|
||||
terminalConfig: {
|
||||
theme: "termix",
|
||||
sudoPassword: "nested-sudo",
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
},
|
||||
};
|
||||
|
||||
it("always strips secrets for recipients", () => {
|
||||
@@ -277,6 +348,12 @@ describe("sanitizeHostForRecipient", () => {
|
||||
expect(result.sudoPassword).toBeUndefined();
|
||||
expect(result.rdpPassword).toBeUndefined();
|
||||
expect(result.socks5Password).toBeUndefined();
|
||||
expect(result.credentialId).toBeUndefined();
|
||||
expect(result.overrideCredentialUsername).toBeUndefined();
|
||||
expect(result.terminalConfig).toEqual({ theme: "termix" });
|
||||
expect(result.shareSshAuth).toBe(true);
|
||||
expect(result.hasPassword).toBe(false);
|
||||
expect(result.hasKey).toBe(false);
|
||||
// view keeps configuration fields
|
||||
expect(result.notes).toBe("secret runbook");
|
||||
expect(result.quickActions).toEqual(sharedHost.quickActions);
|
||||
@@ -284,7 +361,17 @@ describe("sanitizeHostForRecipient", () => {
|
||||
|
||||
it("reduces connect-level hosts to connection essentials", () => {
|
||||
const result = sanitizeHostForRecipient(
|
||||
{ ...sharedHost, permissionLevel: "connect" },
|
||||
{
|
||||
...sharedHost,
|
||||
permissionLevel: "connect",
|
||||
authOverrides: {
|
||||
ssh: {
|
||||
credentialId: 9,
|
||||
required: false,
|
||||
ownerAuthShared: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"connect",
|
||||
);
|
||||
expect(result.name).toBe("prod");
|
||||
@@ -292,6 +379,14 @@ describe("sanitizeHostForRecipient", () => {
|
||||
expect(result.enableRdp).toBe(true);
|
||||
expect(result.rdpPort).toBe(3389);
|
||||
expect(result.permissionLevel).toBe("connect");
|
||||
expect(result.shareSshAuth).toBe(true);
|
||||
expect(result.authOverrides).toEqual({
|
||||
ssh: {
|
||||
credentialId: 9,
|
||||
required: false,
|
||||
ownerAuthShared: true,
|
||||
},
|
||||
});
|
||||
expect(result.notes).toBeUndefined();
|
||||
expect(result.quickActions).toBeUndefined();
|
||||
expect(result.password).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProxmoxImportAuth } from "../../../database/routes/proxmox-import-auth.js";
|
||||
|
||||
// The frontend carries its own copy of this decision in
|
||||
// src/ui/components/proxmox/proxmox-import-auth.ts. The two drifting apart is
|
||||
// what produced the reported bug, so both are held to the same matrix.
|
||||
describe("resolveProxmoxImportAuth", () => {
|
||||
it("uses the default credential for key auth when one is configured", () => {
|
||||
expect(resolveProxmoxImportAuth("key", 7)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 7,
|
||||
overrideCredentialUsername: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the default credential for password auth when one is configured", () => {
|
||||
expect(resolveProxmoxImportAuth("password", 7)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 7,
|
||||
overrideCredentialUsername: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to none when a secret-backed default has no credential", () => {
|
||||
for (const authType of ["password", "key", "credential"]) {
|
||||
expect(resolveProxmoxImportAuth(authType, null)).toEqual({
|
||||
authType: "none",
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the credential when no default auth type is configured", () => {
|
||||
expect(resolveProxmoxImportAuth(undefined, 42)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 42,
|
||||
overrideCredentialUsername: 1,
|
||||
});
|
||||
expect(resolveProxmoxImportAuth(undefined, null)).toEqual({
|
||||
authType: "none",
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps secretless auth types, with or without a credential", () => {
|
||||
for (const authType of ["none", "agent", "opkssh", "tailscale", "vault"]) {
|
||||
for (const credentialId of [null, 7]) {
|
||||
expect(resolveProxmoxImportAuth(authType, credentialId)).toEqual({
|
||||
authType,
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import express from "express";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
authenticated: true,
|
||||
access: {
|
||||
hasAccess: true,
|
||||
isShared: true,
|
||||
isAdminBypass: false,
|
||||
},
|
||||
credentialOwned: true,
|
||||
credentialId: 7 as number | null,
|
||||
writes: [] as Array<{ protocol: string; credentialId: number | null }>,
|
||||
auditCalls: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(
|
||||
req: express.Request & { userId?: string },
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
if (!state.authenticated) {
|
||||
res.status(401).json({ error: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
req.userId = "recipient";
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware:
|
||||
() =>
|
||||
(
|
||||
_req: express.Request,
|
||||
_res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
SHARE_PERMISSION_LEVELS: ["connect", "view", "edit", "manage"],
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async () => state.access,
|
||||
requireAdmin:
|
||||
() =>
|
||||
(
|
||||
_req: express.Request,
|
||||
_res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) =>
|
||||
next(),
|
||||
invalidateUserPermissionCache: vi.fn(),
|
||||
isAdmin: async () => false,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentCredentialRepository: () => ({
|
||||
findByIdForUser: async () =>
|
||||
state.credentialOwned ? { id: state.credentialId } : null,
|
||||
}),
|
||||
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||
findCredentialId: async () => state.credentialId,
|
||||
setCredential: async (
|
||||
_hostId: number,
|
||||
_userId: string,
|
||||
protocol: string,
|
||||
id: number,
|
||||
) => {
|
||||
state.credentialId = id;
|
||||
state.writes.push({ protocol, credentialId: id });
|
||||
},
|
||||
clearCredential: async (
|
||||
_hostId: number,
|
||||
_userId: string,
|
||||
protocol: string,
|
||||
) => {
|
||||
state.credentialId = null;
|
||||
state.writes.push({ protocol, credentialId: null });
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({ id: "recipient", username: "recipient" }),
|
||||
}),
|
||||
createCurrentHostFolderRepository: vi.fn(),
|
||||
createCurrentHostResolutionRepository: vi.fn(),
|
||||
createCurrentRbacAccessRepository: vi.fn(),
|
||||
createCurrentRoleRepository: vi.fn(),
|
||||
createCurrentSnippetRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
logAudit: vi.fn(async (entry: Record<string, unknown>) => {
|
||||
state.auditCalls.push(entry);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("shared host authentication override routes", () => {
|
||||
let router: express.Router;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ default: router } = await import("../../../database/routes/rbac.js"));
|
||||
});
|
||||
|
||||
async function invoke(
|
||||
method: "get" | "put",
|
||||
body: Record<string, unknown> = {},
|
||||
protocol = "ssh",
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const routeLayer = (
|
||||
router as unknown as {
|
||||
stack: Array<{
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{
|
||||
handle: (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => unknown;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
).stack.find(
|
||||
(layer) =>
|
||||
layer.route?.path === "/host-access/:hostId/auth/:protocol" &&
|
||||
layer.route.methods[method],
|
||||
);
|
||||
if (!routeLayer?.route) throw new Error(`Missing ${method} route`);
|
||||
|
||||
const handlers = routeLayer.route.stack.map((layer) => layer.handle);
|
||||
const req = {
|
||||
params: { hostId: "42", protocol },
|
||||
body,
|
||||
headers: {},
|
||||
ip: "127.0.0.1",
|
||||
} as unknown as express.Request;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let index = 0;
|
||||
let status = 200;
|
||||
const res = {
|
||||
status(code: number) {
|
||||
status = code;
|
||||
return this;
|
||||
},
|
||||
json(responseBody: unknown) {
|
||||
resolve({ status, body: responseBody });
|
||||
return this;
|
||||
},
|
||||
} as unknown as express.Response;
|
||||
|
||||
const next: express.NextFunction = (error?: unknown) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
const handler = handlers[index++];
|
||||
if (!handler) {
|
||||
resolve({ status, body: undefined });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Promise.resolve(handler(req, res, next)).catch(reject);
|
||||
} catch (handlerError) {
|
||||
reject(handlerError);
|
||||
}
|
||||
};
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.authenticated = true;
|
||||
state.access = {
|
||||
hasAccess: true,
|
||||
isShared: true,
|
||||
isAdminBypass: false,
|
||||
};
|
||||
state.credentialOwned = true;
|
||||
state.credentialId = 7;
|
||||
state.writes = [];
|
||||
state.auditCalls = [];
|
||||
});
|
||||
|
||||
it("returns the current override for a role-derived shared recipient", async () => {
|
||||
const response = await invoke("get");
|
||||
expect(response).toEqual({
|
||||
status: 200,
|
||||
body: { protocol: "ssh", credentialId: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
it("sets and clears a direct recipient's own credential", async () => {
|
||||
const setResponse = await invoke("put", { credentialId: 8 });
|
||||
expect(setResponse.status).toBe(200);
|
||||
expect(state.writes).toEqual([{ protocol: "ssh", credentialId: 8 }]);
|
||||
|
||||
const clearResponse = await invoke("put", { credentialId: null });
|
||||
expect(clearResponse.status).toBe(200);
|
||||
expect(state.writes).toEqual([
|
||||
{ protocol: "ssh", credentialId: 8 },
|
||||
{ protocol: "ssh", credentialId: null },
|
||||
]);
|
||||
expect(state.auditCalls).toHaveLength(2);
|
||||
expect(JSON.parse(String(state.auditCalls[0].details))).toEqual({
|
||||
protocol: "ssh",
|
||||
credentialId: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects owners, admin bypasses, and users without active access", async () => {
|
||||
for (const access of [
|
||||
{ hasAccess: true, isShared: false, isAdminBypass: false },
|
||||
{ hasAccess: true, isShared: false, isAdminBypass: true },
|
||||
{ hasAccess: false, isShared: true, isAdminBypass: false },
|
||||
]) {
|
||||
state.access = access;
|
||||
const response = await invoke("get");
|
||||
expect(response.status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid or foreign credentials and unauthenticated requests", async () => {
|
||||
const invalidResponse = await invoke("put", { credentialId: 0 });
|
||||
expect(invalidResponse.status).toBe(400);
|
||||
|
||||
state.credentialOwned = false;
|
||||
const foreignResponse = await invoke("put", { credentialId: 99 });
|
||||
expect(foreignResponse.status).toBe(404);
|
||||
|
||||
state.authenticated = false;
|
||||
const unauthenticatedResponse = await invoke("get");
|
||||
expect(unauthenticatedResponse.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects recognized but unsupported protocols and invalid protocol names", async () => {
|
||||
const unsupportedResponse = await invoke("get", {}, "rdp");
|
||||
expect(unsupportedResponse).toEqual({
|
||||
status: 400,
|
||||
body: {
|
||||
error: "RDP authentication overrides are not supported yet",
|
||||
},
|
||||
});
|
||||
expect(state.writes).toEqual([]);
|
||||
|
||||
const invalidResponse = await invoke("get", {}, "smtp");
|
||||
expect(invalidResponse).toEqual({
|
||||
status: 400,
|
||||
body: { error: "Invalid authentication protocol" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,25 @@ vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// The route module calls PermissionManager.getInstance() at import time and
|
||||
// pulls in the repository factory, which loads the drizzle schema and the
|
||||
// better-sqlite3 native binding. Importing that tree costs seconds under a
|
||||
// concurrent full run — enough to blow the 5s test timeout — and none of it is
|
||||
// under test here.
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSessionRecordingRepository: vi.fn(),
|
||||
createCurrentSettingsRepository: vi.fn(),
|
||||
getCurrentSettingValue: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockReadFile = vi.fn();
|
||||
const mockStat = vi.fn();
|
||||
const mockUnlink = vi.fn();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSnippetExecutionResult,
|
||||
getSnippetExecutionTimeoutMs,
|
||||
} from "../../../database/routes/snippets-execution.js";
|
||||
|
||||
describe("snippet execution", () => {
|
||||
it("treats stderr as diagnostic output when the command succeeds", () => {
|
||||
expect(createSnippetExecutionResult(0, "done\n", "warning\n")).toEqual({
|
||||
success: true,
|
||||
output: "done\n",
|
||||
error: "warning\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the exit code to report command failure", () => {
|
||||
expect(createSnippetExecutionResult(1, "", "failed\n")).toEqual({
|
||||
success: false,
|
||||
output: "",
|
||||
error: "failed\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the previous fallback when no exit code is available", () => {
|
||||
expect(createSnippetExecutionResult(null, "done\n", "")).toEqual({
|
||||
success: true,
|
||||
output: "done\n",
|
||||
});
|
||||
expect(createSnippetExecutionResult(null, "", "failed\n").success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables the command timeout by default", () => {
|
||||
expect(getSnippetExecutionTimeoutMs(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("converts a configured timeout from seconds to milliseconds", () => {
|
||||
expect(getSnippetExecutionTimeoutMs("45")).toBe(45_000);
|
||||
});
|
||||
|
||||
it.each(["", "0", "-1", "invalid"])(
|
||||
"ignores invalid timeout value %j",
|
||||
(value) => {
|
||||
expect(getSnippetExecutionTimeoutMs(value)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,9 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
import syncRouter, {
|
||||
isValidEntityType,
|
||||
stripWritePayload,
|
||||
} from "../../../database/routes/sync.js";
|
||||
|
||||
describe("sync route order", () => {
|
||||
it("registers POST /tombstones before the POST /:entityType wildcard", () => {
|
||||
const postPaths = (
|
||||
syncRouter as unknown as {
|
||||
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
|
||||
}
|
||||
).stack
|
||||
.filter((layer) => layer.route?.methods?.post)
|
||||
.map((layer) => layer.route!.path);
|
||||
|
||||
// "/tombstones" is a valid value for :entityType as far as Express is
|
||||
// concerned, so registering the wildcard first makes the tombstone
|
||||
// endpoint unreachable -- every deletion push answers 400 "Unknown entity
|
||||
// type" instead of applying the deletion.
|
||||
expect(postPaths).toContain("/tombstones");
|
||||
expect(postPaths.indexOf("/tombstones")).toBeLessThan(
|
||||
postPaths.indexOf("/:entityType"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidEntityType", () => {
|
||||
it("accepts every whitelisted sync entity type", () => {
|
||||
for (const type of [
|
||||
@@ -15,6 +36,7 @@ describe("isValidEntityType", () => {
|
||||
"vaultProfiles",
|
||||
"dashboardServiceLinks",
|
||||
"homepageItems",
|
||||
"userPreferences",
|
||||
]) {
|
||||
expect(isValidEntityType(type)).toBe(true);
|
||||
}
|
||||
@@ -52,6 +74,16 @@ describe("stripWritePayload", () => {
|
||||
expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" });
|
||||
});
|
||||
|
||||
it("keeps preference storage mode local to each device", () => {
|
||||
expect(
|
||||
stripWritePayload("userPreferences", {
|
||||
syncId: "userPreferences:singleton",
|
||||
theme: "dark",
|
||||
storageMode: "cloud",
|
||||
}),
|
||||
).toEqual({ theme: "dark" });
|
||||
});
|
||||
|
||||
it("does not mutate the original payload object", () => {
|
||||
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
|
||||
stripWritePayload("snippets", payload);
|
||||
|
||||
@@ -16,11 +16,149 @@ const {
|
||||
getOIDCConfigFromEnv,
|
||||
extractOidcGroups,
|
||||
validateLogoutTokenClaims,
|
||||
parseOidcRoleMap,
|
||||
resolveOidcMappedRoles,
|
||||
verifyOIDCToken,
|
||||
describeFetchFailure,
|
||||
} = await import("../../../database/routes/user-oidc-utils.js");
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT =
|
||||
"http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("describeFetchFailure", () => {
|
||||
it("unwraps the undici cause, which carries the reason that matters", () => {
|
||||
// Every transport failure surfaces as this same outer message.
|
||||
const error = new TypeError("fetch failed", {
|
||||
cause: Object.assign(new Error("getaddrinfo ENOTFOUND idp.example"), {
|
||||
code: "ENOTFOUND",
|
||||
}),
|
||||
});
|
||||
expect(describeFetchFailure(error)).toBe(
|
||||
"fetch failed: getaddrinfo ENOTFOUND idp.example (ENOTFOUND)",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the outer message when there is no cause", () => {
|
||||
expect(describeFetchFailure(new Error("boom"))).toBe("boom");
|
||||
});
|
||||
|
||||
it("handles a non-Error throw", () => {
|
||||
expect(describeFetchFailure("nope")).toBe("nope");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyOIDCToken JWKS diagnostics", () => {
|
||||
const issuer = "https://login.microsoftonline.com/example/v2.0";
|
||||
const token = "header.payload.signature";
|
||||
|
||||
it("reports every attempted URL and why it failed", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("not found", { status: 404 }),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toMatch(/^Failed to fetch JWKS from any URL/);
|
||||
expect(error.message).toContain(
|
||||
`${issuer}/.well-known/openid-configuration: HTTP 404`,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
`${issuer}/.well-known/jwks.json: HTTP 404`,
|
||||
);
|
||||
expect(error.message).toContain(`${issuer}/jwks/: HTTP 404`);
|
||||
});
|
||||
|
||||
it("reports a transport failure with its underlying cause", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new TypeError("fetch failed", {
|
||||
cause: Object.assign(new Error("self-signed certificate"), {
|
||||
code: "SELF_SIGNED_CERT_IN_CHAIN",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
"self-signed certificate (SELF_SIGNED_CERT_IN_CHAIN)",
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when discovery succeeds but advertises no jwks_uri", async () => {
|
||||
vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ issuer }), { status: 200 }),
|
||||
)
|
||||
.mockResolvedValue(new Response("not found", { status: 404 }));
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain("no jwks_uri in the discovery document");
|
||||
});
|
||||
|
||||
it("says so when a JWKS response carries no keys array", async () => {
|
||||
vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
'https://idp.example/keys: response contains no "keys" array',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyOIDCToken", () => {
|
||||
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");
|
||||
const jwk = await exportJWK(publicKey);
|
||||
jwk.kid = "entra-key";
|
||||
|
||||
const issuer = "https://login.microsoftonline.com/example/v2.0";
|
||||
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: "https://idp.example/keys" }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const payload = await verifyOIDCToken(token, issuer, clientId);
|
||||
|
||||
expect(payload.sub).toBe("user-1");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOIDCUserAllowed", () => {
|
||||
it("allows everyone when the allow-list is empty", () => {
|
||||
expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true);
|
||||
@@ -251,3 +389,143 @@ describe("validateLogoutTokenClaims", () => {
|
||||
).toThrow("must contain sub and/or sid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOidcRoleMap", () => {
|
||||
it("returns an empty map for blank input", () => {
|
||||
expect(parseOidcRoleMap(undefined).size).toBe(0);
|
||||
expect(parseOidcRoleMap(null).size).toBe(0);
|
||||
expect(parseOidcRoleMap(" ").size).toBe(0);
|
||||
});
|
||||
|
||||
it("parses comma-separated group:role pairs", () => {
|
||||
const map = parseOidcRoleMap(
|
||||
"devops-interns:devops-intern,devops-seniors:devops-senior",
|
||||
);
|
||||
expect(map.get("devops-interns")).toBe("devops-intern");
|
||||
expect(map.get("devops-seniors")).toBe("devops-senior");
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
|
||||
it("parses newline-separated pairs and trims whitespace", () => {
|
||||
const map = parseOidcRoleMap(" a : role-a \n b:role-b \n");
|
||||
expect(map.get("a")).toBe("role-a");
|
||||
expect(map.get("b")).toBe("role-b");
|
||||
});
|
||||
|
||||
it("normalizes leading slashes and case in group names", () => {
|
||||
const map = parseOidcRoleMap("/DevOps-Interns:devops-intern");
|
||||
expect(map.get("devops-interns")).toBe("devops-intern");
|
||||
});
|
||||
|
||||
it("skips malformed entries instead of throwing", () => {
|
||||
const map = parseOidcRoleMap("no-colon,:missing-group,missing-role:,ok:r");
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get("ok")).toBe("r");
|
||||
});
|
||||
|
||||
it("splits on the last colon so group names may contain colons", () => {
|
||||
const map = parseOidcRoleMap("ns:team:role-x");
|
||||
expect(map.get("ns:team")).toBe("role-x");
|
||||
});
|
||||
|
||||
it("preserves role-name case verbatim", () => {
|
||||
// Role names must match roles.name exactly, so they are not lowercased.
|
||||
expect(parseOidcRoleMap("g:DevOps_Senior").get("g")).toBe("DevOps_Senior");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOidcMappedRoles", () => {
|
||||
const roleMap = parseOidcRoleMap(
|
||||
"devops-interns:devops-intern,devops-seniors:devops-senior",
|
||||
);
|
||||
|
||||
it("reports every mapped role as managed regardless of membership", () => {
|
||||
const { managed } = resolveOidcMappedRoles([], roleMap);
|
||||
expect([...managed].sort()).toEqual(["devops-intern", "devops-senior"]);
|
||||
});
|
||||
|
||||
it("desires only the roles whose groups the user is in", () => {
|
||||
const { desired } = resolveOidcMappedRoles(["devops-interns"], roleMap);
|
||||
expect([...desired]).toEqual(["devops-intern"]);
|
||||
});
|
||||
|
||||
it("matches full group paths emitted by Keycloak", () => {
|
||||
const { desired } = resolveOidcMappedRoles(["/devops-seniors"], roleMap);
|
||||
expect([...desired]).toEqual(["devops-senior"]);
|
||||
});
|
||||
|
||||
it("ignores groups that are not mapped", () => {
|
||||
const { desired } = resolveOidcMappedRoles(
|
||||
["finance", "devops-interns"],
|
||||
roleMap,
|
||||
);
|
||||
expect([...desired]).toEqual(["devops-intern"]);
|
||||
});
|
||||
|
||||
it("supports a user in multiple mapped groups", () => {
|
||||
const { desired } = resolveOidcMappedRoles(
|
||||
["devops-interns", "devops-seniors"],
|
||||
roleMap,
|
||||
);
|
||||
expect([...desired].sort()).toEqual(["devops-intern", "devops-senior"]);
|
||||
});
|
||||
|
||||
it("desires nothing when the map is empty", () => {
|
||||
const { desired, managed } = resolveOidcMappedRoles(
|
||||
["devops-interns"],
|
||||
new Map(),
|
||||
);
|
||||
expect(desired.size).toBe(0);
|
||||
expect(managed.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Imported as a namespace rather than destructured into the shared block at the
|
||||
// top of the file, so this suite stays independent of what that block binds.
|
||||
const oidcUtils = await import("../../../database/routes/user-oidc-utils.js");
|
||||
|
||||
describe("verifyOIDCToken token shape", () => {
|
||||
const issuer = "https://idp.example.com/application/o/termix";
|
||||
|
||||
// The shape check runs before any network call, so no fetch stub is needed.
|
||||
const fetchSpy = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
fetchSpy.mockReset();
|
||||
});
|
||||
|
||||
it("reports an encrypted (JWE) token as a format error", async () => {
|
||||
const jwe = ["header", "key", "iv", "ciphertext", "tag"].join(".");
|
||||
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
|
||||
).rejects.toThrow(oidcUtils.OIDCTokenFormatError);
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
|
||||
).rejects.toThrow(/JWE \(encrypted\)/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports any other non-JWS segment count as a format error", async () => {
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("header.payload", issuer, "client"),
|
||||
).rejects.toThrow(/expected 3 segments, got 2/);
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("opaque", issuer, "client"),
|
||||
).rejects.toThrow(/expected 3 segments, got 1/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a three-segment token through to key resolution", async () => {
|
||||
fetchSpy.mockResolvedValue({ ok: false });
|
||||
|
||||
// Reaches JWKS fetching, so it fails on the key lookup rather than the shape.
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("header.payload.signature", issuer, "client"),
|
||||
).rejects.not.toThrow(oidcUtils.OIDCTokenFormatError);
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deserializeSyncReferences,
|
||||
serializeSyncReferences,
|
||||
} from "../../database/routes/sync-references.js";
|
||||
|
||||
describe("sync references", () => {
|
||||
it("serializes database-local host IDs as stable sync IDs", async () => {
|
||||
const row = await serializeSyncReferences(
|
||||
"hosts",
|
||||
{
|
||||
id: 7,
|
||||
credentialId: 12,
|
||||
rdpCredentialId: 13,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: 4,
|
||||
},
|
||||
async (entityType, id) => `${entityType}-${id}`,
|
||||
);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
credentialSyncId: "sshCredentials-12",
|
||||
rdpCredentialSyncId: "sshCredentials-13",
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
vaultProfileSyncId: "vaultProfiles-4",
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialId");
|
||||
expect(row).not.toHaveProperty("vaultProfileId");
|
||||
});
|
||||
|
||||
it("resolves stable sync IDs to IDs from the receiving database", async () => {
|
||||
const ids = new Map([
|
||||
["sshCredentials:credential-sync", 91],
|
||||
["vaultProfiles:vault-sync", 37],
|
||||
]);
|
||||
const row = await deserializeSyncReferences(
|
||||
"hosts",
|
||||
{
|
||||
credentialId: 12,
|
||||
credentialSyncId: "credential-sync",
|
||||
rdpCredentialSyncId: null,
|
||||
vncCredentialSyncId: null,
|
||||
telnetCredentialSyncId: null,
|
||||
vaultProfileSyncId: "vault-sync",
|
||||
},
|
||||
async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null,
|
||||
);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
credentialId: 91,
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
vaultProfileId: 37,
|
||||
});
|
||||
expect(row).not.toHaveProperty("credentialSyncId");
|
||||
});
|
||||
|
||||
it("rejects a row whose referenced dependency has not synced", async () => {
|
||||
await expect(
|
||||
deserializeSyncReferences(
|
||||
"sshFolders",
|
||||
{ credentialSyncId: "missing" },
|
||||
async () => null,
|
||||
),
|
||||
).rejects.toThrow("Missing sshCredentials dependency");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { getUnpackedAppRoot } =
|
||||
require("../../../../electron/backend-paths.cjs") as {
|
||||
getUnpackedAppRoot: (appRoot: string) => string;
|
||||
};
|
||||
|
||||
describe("getUnpackedAppRoot", () => {
|
||||
it.each([
|
||||
[
|
||||
"/Applications/Termix.app/Contents/Resources/app.asar",
|
||||
"/Applications/Termix.app/Contents/Resources/app.asar.unpacked",
|
||||
],
|
||||
[
|
||||
"/Applications/Termix.app/Contents/Resources/app-arm64.asar",
|
||||
"/Applications/Termix.app/Contents/Resources/app-arm64.asar.unpacked",
|
||||
],
|
||||
[
|
||||
"/Applications/Termix.app/Contents/Resources/app-x64.asar",
|
||||
"/Applications/Termix.app/Contents/Resources/app-x64.asar.unpacked",
|
||||
],
|
||||
])("maps %s to its matching unpacked directory", (appRoot, expected) => {
|
||||
expect(getUnpackedAppRoot(appRoot)).toBe(expected);
|
||||
});
|
||||
|
||||
it("does not append the suffix twice", () => {
|
||||
const appRoot =
|
||||
"/Applications/Termix.app/Contents/Resources/app-arm64.asar.unpacked";
|
||||
expect(getUnpackedAppRoot(appRoot)).toBe(appRoot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveJumpTunnelEndpoint } from "../../../hosts/guacamole/jump-tunnel-endpoint.js";
|
||||
|
||||
describe("resolveJumpTunnelEndpoint", () => {
|
||||
it("keeps an in-process guacd tunnel on loopback", () => {
|
||||
expect(resolveJumpTunnelEndpoint("localhost")).toEqual({
|
||||
bindHost: "127.0.0.1",
|
||||
advertisedHost: "127.0.0.1",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the tunnel to a separate guacd container", () => {
|
||||
expect(resolveJumpTunnelEndpoint("guacd")).toEqual({
|
||||
bindHost: "0.0.0.0",
|
||||
advertisedHost: "termix",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports a custom backend hostname for external guacd", () => {
|
||||
expect(
|
||||
resolveJumpTunnelEndpoint("guacd.example", "termix-backend"),
|
||||
).toEqual({
|
||||
bindHost: "0.0.0.0",
|
||||
advertisedHost: "termix-backend",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withRecordingSettings } from "../../../hosts/guacamole/recording-settings.js";
|
||||
|
||||
const PATH = "/app/data/session_recordings/guacamole";
|
||||
const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac";
|
||||
|
||||
describe("withRecordingSettings", () => {
|
||||
it("takes ownership of the location and filename", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{
|
||||
"recording-path": "/var/lib/termix/recordings",
|
||||
"recording-name": "${GUAC_USERNAME}-${GUAC_DATE}",
|
||||
"create-recording-path": false,
|
||||
},
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-path": PATH,
|
||||
"recording-name": NAME,
|
||||
"create-recording-path": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the content flags when the host has no opinion", () => {
|
||||
expect(withRecordingSettings({}, PATH, NAME)).toMatchObject({
|
||||
"recording-exclude-output": false,
|
||||
"recording-include-keys": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the host's content flags, including the falsy ones", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{
|
||||
"recording-exclude-output": true,
|
||||
"recording-include-keys": false,
|
||||
},
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-exclude-output": true,
|
||||
"recording-include-keys": false,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unrelated settings alone", () => {
|
||||
const merged = withRecordingSettings(
|
||||
{ "recording-exclude-mouse": true, width: "1920" },
|
||||
PATH,
|
||||
NAME,
|
||||
);
|
||||
|
||||
expect(merged).toMatchObject({
|
||||
"recording-exclude-mouse": true,
|
||||
width: "1920",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the settings it was given", () => {
|
||||
const original = { "recording-path": "/tmp/mine" };
|
||||
withRecordingSettings(original, PATH, NAME);
|
||||
|
||||
expect(original).toEqual({ "recording-path": "/tmp/mine" });
|
||||
});
|
||||
});
|
||||
@@ -6,22 +6,25 @@ const state = vi.hoisted(() => ({
|
||||
isAdminBypass: false,
|
||||
overrideCredentialId: null as number | null,
|
||||
credentials: new Map<string, Record<string, unknown>>(),
|
||||
sharedSecret: null as Record<string, unknown> | null,
|
||||
vaultProfile: null as Record<string, unknown> | null,
|
||||
auditCalls: [] as Record<string, unknown>[],
|
||||
folderCredentialId: null as number | null,
|
||||
sharedSecret: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostOwnerId: async () => (state.host?.userId as string) ?? null,
|
||||
findHostById: async () => (state.host ? { ...state.host } : null),
|
||||
findOverrideCredentialId: async () => state.overrideCredentialId,
|
||||
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
|
||||
state.credentials.get(`${credentialId}:${userId}`) ?? null,
|
||||
findFolderCredentialId: async () => state.folderCredentialId,
|
||||
}),
|
||||
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||
findCredentialId: async () => state.overrideCredentialId,
|
||||
}),
|
||||
createCurrentVaultProfileRepository: () => ({
|
||||
findById: async () => null,
|
||||
findById: async () => state.vaultProfile,
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (userId: string) => ({ id: userId, username: userId }),
|
||||
@@ -79,6 +82,7 @@ function baseHost(overrides: Record<string, unknown> = {}) {
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
credentialId: null,
|
||||
shareSshAuth: false,
|
||||
vaultProfileId: null,
|
||||
sudoPassword: "owner-sudo",
|
||||
autostartPassword: "auto-pass",
|
||||
@@ -101,9 +105,10 @@ beforeEach(() => {
|
||||
state.isAdminBypass = false;
|
||||
state.overrideCredentialId = null;
|
||||
state.credentials.clear();
|
||||
state.sharedSecret = null;
|
||||
state.vaultProfile = null;
|
||||
state.auditCalls = [];
|
||||
state.folderCredentialId = null;
|
||||
state.sharedSecret = null;
|
||||
});
|
||||
|
||||
describe("resolveHostById", () => {
|
||||
@@ -198,8 +203,12 @@ describe("resolveHostById", () => {
|
||||
expect(host.password).toBe("host-pass");
|
||||
});
|
||||
|
||||
it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => {
|
||||
state.host = baseHost({ username: "" });
|
||||
it("does not expose the owner's secret-backed SSH authentication", async () => {
|
||||
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the owner-provided SSH snapshot when sharing is enabled", async () => {
|
||||
state.host = baseHost({ shareSshAuth: true, username: "host-user" });
|
||||
state.sharedSecret = {
|
||||
username: "shared-user",
|
||||
authType: "password",
|
||||
@@ -210,14 +219,56 @@ describe("resolveHostById", () => {
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.username).toBe("host-user");
|
||||
expect(host.password).toBe("shared-pass");
|
||||
expect(host.username).toBe("shared-user");
|
||||
expect(host.sudoPassword).toBeNull();
|
||||
expect(host.autostartPassword).toBeNull();
|
||||
expect(host.authType).toBe("password");
|
||||
});
|
||||
|
||||
it("prefers the recipient's override credential over the snapshot", async () => {
|
||||
state.host = baseHost({ username: "" });
|
||||
it("denies shared secret-backed auth when the opted-in snapshot is missing", async () => {
|
||||
state.host = baseHost({ shareSshAuth: true });
|
||||
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps SSH agent authentication private unless the owner opts in", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "agent",
|
||||
password: null,
|
||||
terminalConfig: JSON.stringify({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows SSH agent authentication after the owner explicitly opts in", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "agent",
|
||||
password: null,
|
||||
shareSshAuth: true,
|
||||
terminalConfig: JSON.stringify({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
}),
|
||||
});
|
||||
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.authType).toBe("agent");
|
||||
expect(host.terminalConfig).toEqual({
|
||||
agentSocketPath: "/run/user/1000/ssh-agent.sock",
|
||||
sudoPassword: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the recipient's credential instead of the owner's authentication", async () => {
|
||||
state.host = baseHost({ username: "", shareSshAuth: true });
|
||||
state.sharedSecret = {
|
||||
username: "shared-user",
|
||||
authType: "password",
|
||||
password: "shared-pass",
|
||||
};
|
||||
state.overrideCredentialId = 5;
|
||||
state.credentials.set("5:recipient", {
|
||||
id: 5,
|
||||
@@ -229,11 +280,6 @@ describe("resolveHostById", () => {
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
});
|
||||
state.sharedSecret = {
|
||||
username: "shared-user",
|
||||
authType: "password",
|
||||
password: "shared-pass",
|
||||
};
|
||||
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
@@ -243,14 +289,122 @@ describe("resolveHostById", () => {
|
||||
expect(host.username).toBe("my-user");
|
||||
});
|
||||
|
||||
it("denies a non-owner when a secret-bearing host has no snapshot", async () => {
|
||||
it("uses the recipient credential username even when the owner forces their own credential username", async () => {
|
||||
state.host = baseHost({
|
||||
username: "owner-login",
|
||||
overrideCredentialUsername: true,
|
||||
});
|
||||
state.overrideCredentialId = 5;
|
||||
state.credentials.set("5:recipient", {
|
||||
id: 5,
|
||||
username: "recipient-login",
|
||||
authType: "key",
|
||||
password: null,
|
||||
privateKey: "RECIPIENT-KEY",
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: "ssh-ed25519",
|
||||
});
|
||||
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.username).toBe("recipient-login");
|
||||
expect(host.authType).toBe("key");
|
||||
expect(host.key).toBe("RECIPIENT-KEY");
|
||||
});
|
||||
|
||||
it("fully replaces Vault authentication with the recipient override", async () => {
|
||||
state.host = baseHost({
|
||||
authType: "vault",
|
||||
password: null,
|
||||
vaultProfileId: 7,
|
||||
});
|
||||
state.vaultProfile = { id: 7 };
|
||||
state.overrideCredentialId = 5;
|
||||
state.credentials.set("5:recipient", {
|
||||
id: 5,
|
||||
username: "recipient-login",
|
||||
authType: "key",
|
||||
password: null,
|
||||
privateKey: "RECIPIENT-KEY",
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: "ssh-ed25519",
|
||||
certPublicKey: "ssh-ed25519-cert-v01@example certificate",
|
||||
});
|
||||
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.authType).toBe("key");
|
||||
expect(host.key).toBe("RECIPIENT-KEY");
|
||||
expect(host.certPublicKey).toBe("ssh-ed25519-cert-v01@example certificate");
|
||||
expect(host.vaultProfile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the host username when the override credential has none", async () => {
|
||||
state.host = baseHost({ username: "shared-login" });
|
||||
state.overrideCredentialId = 5;
|
||||
state.credentials.set("5:recipient", {
|
||||
id: 5,
|
||||
username: null,
|
||||
authType: "password",
|
||||
password: "my-pass",
|
||||
privateKey: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
});
|
||||
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.username).toBe("shared-login");
|
||||
});
|
||||
|
||||
it("denies a non-owner when a secret-bearing host has no personal credential", async () => {
|
||||
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a stored override when shared access is inactive", async () => {
|
||||
state.hasAccess = false;
|
||||
state.overrideCredentialId = 5;
|
||||
state.credentials.set("5:recipient", {
|
||||
id: 5,
|
||||
username: "recipient",
|
||||
authType: "password",
|
||||
password: "my-pass",
|
||||
});
|
||||
|
||||
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||
});
|
||||
|
||||
it("lets a non-owner through on secret-less auth types without a snapshot", async () => {
|
||||
state.host = baseHost({ authType: "none", password: null });
|
||||
const host = await resolveHostById(42, "recipient");
|
||||
expect(host).not.toBeNull();
|
||||
state.host = baseHost({
|
||||
authType: "none",
|
||||
password: "stale-owner-password",
|
||||
key: "stale-owner-key",
|
||||
credentialId: null,
|
||||
terminalConfig: JSON.stringify({
|
||||
theme: "termix",
|
||||
sudoPassword: "owner-sudo",
|
||||
}),
|
||||
});
|
||||
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.password).toBeNull();
|
||||
expect(host.key).toBeNull();
|
||||
expect(host.credentialId).toBeNull();
|
||||
expect(host.terminalConfig).toEqual({
|
||||
theme: "termix",
|
||||
sudoPassword: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => {
|
||||
@@ -301,4 +455,26 @@ describe("resolveHostById", () => {
|
||||
await resolveHostById(42, "owner");
|
||||
expect(state.auditCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses an empty port_knock_sequence '[]' string into an empty array (no bogus knock)", async () => {
|
||||
state.host = baseHost({ portKnockSequence: "[]" });
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.portKnockSequence).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses a real port_knock_sequence JSON string into an array", async () => {
|
||||
state.host = baseHost({
|
||||
portKnockSequence: '[{"port":1234,"protocol":"tcp","delay":100}]',
|
||||
});
|
||||
const host = (await resolveHostById(42, "owner")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(host.portKnockSequence).toEqual([
|
||||
{ port: 1234, protocol: "tcp", delay: 100 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// Regression guard for: the /internal/login-alert route was registered
|
||||
// after the global JWT auth middleware, so every service-to-service login
|
||||
// alert got rejected with 401 before the route's own IP+token check ever
|
||||
// ran. Spinning up the full metrics-service Express app (DB, SSH clients,
|
||||
// polling managers, etc.) just to hit this one route is out of scope, so
|
||||
// this asserts the registration order directly against the source instead.
|
||||
describe("metrics service /internal/login-alert route order", () => {
|
||||
it("is registered before the global auth middleware", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../../hosts/metrics/index.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const routeIndex = source.indexOf('app.post("/internal/login-alert"');
|
||||
const authMiddlewareIndex = source.indexOf(
|
||||
"app.use(authManager.createAuthMiddleware())",
|
||||
);
|
||||
|
||||
expect(routeIndex).toBeGreaterThan(-1);
|
||||
expect(authMiddlewareIndex).toBeGreaterThan(-1);
|
||||
expect(routeIndex).toBeLessThan(authMiddlewareIndex);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseDfLines,
|
||||
findWorstMountIndex,
|
||||
buildFilesystemList,
|
||||
selectPrimaryFilesystem,
|
||||
} from "../../../../hosts/metrics/widgets/disk-collector.js";
|
||||
|
||||
describe("parseDfLines", () => {
|
||||
@@ -58,3 +60,73 @@ describe("findWorstMountIndex", () => {
|
||||
expect(worst.totalBytes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const BYTES_OUTPUT =
|
||||
"/dev/nvme0n1p2 1000 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 2000 1900 100 95% /data\n";
|
||||
const HUMAN_OUTPUT =
|
||||
"/dev/nvme0n1p2 1.0K 400 600 40% /\n" +
|
||||
"/dev/nvme1n1p1 2.0K 1.9K 100 95% /data\n";
|
||||
|
||||
describe("buildFilesystemList", () => {
|
||||
it("returns every real filesystem with byte maths and human strings", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines(HUMAN_OUTPUT),
|
||||
);
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]).toMatchObject({
|
||||
mount: "/",
|
||||
percent: 40,
|
||||
usedHuman: "400",
|
||||
totalHuman: "1.0K",
|
||||
availableHuman: "600",
|
||||
usedBytes: 400,
|
||||
totalBytes: 1000,
|
||||
});
|
||||
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
|
||||
});
|
||||
|
||||
it("matches human rows by mount when the row counts differ", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines("/dev/nvme1n1p1 2.0K 1.9K 100 95% /data\n"),
|
||||
);
|
||||
expect(list[0].totalHuman).toBeNull();
|
||||
expect(list[1].totalHuman).toBe("2.0K");
|
||||
});
|
||||
|
||||
it("drops filesystems with a zero or invalid total", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines("/dev/sda1 0 0 0 0% /broken\n/dev/sda2 100 40 60 40% /ok\n"),
|
||||
[],
|
||||
);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].mount).toBe("/ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectPrimaryFilesystem", () => {
|
||||
it("prefers root over a fuller secondary mount", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(BYTES_OUTPUT),
|
||||
parseDfLines(HUMAN_OUTPUT),
|
||||
);
|
||||
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
|
||||
});
|
||||
|
||||
it("falls back to the most-utilized mount when there is no root", () => {
|
||||
const list = buildFilesystemList(
|
||||
parseDfLines(
|
||||
"/dev/sda1 1000 100 900 10% /mnt/a\n" +
|
||||
"/dev/sda2 1000 800 200 80% /mnt/b\n",
|
||||
),
|
||||
[],
|
||||
);
|
||||
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
|
||||
});
|
||||
|
||||
it("returns null for an empty list", () => {
|
||||
expect(selectPrimaryFilesystem([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseTailscaleCheckBanner,
|
||||
isTailscaleCheckCompleteBanner,
|
||||
} from "../../hosts/tailscale-check.js";
|
||||
|
||||
describe("parseTailscaleCheckBanner", () => {
|
||||
it("extracts the login URL from a real check-mode banner", () => {
|
||||
const banner =
|
||||
"# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/lefcb2f3377403\n";
|
||||
|
||||
const result = parseTailscaleCheckBanner(banner);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.url).toBe("https://login.tailscale.com/a/lefcb2f3377403");
|
||||
});
|
||||
|
||||
it("strips comment markers from the message it returns", () => {
|
||||
const banner =
|
||||
"# Tailscale SSH requires an additional check.\n# To authenticate, visit: https://login.tailscale.com/a/abc123\n";
|
||||
|
||||
const result = parseTailscaleCheckBanner(banner);
|
||||
|
||||
expect(result?.message).toBe(
|
||||
"Tailscale SSH requires an additional check.\nTo authenticate, visit: https://login.tailscale.com/a/abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for an ordinary MOTD banner", () => {
|
||||
const banner =
|
||||
"Welcome to Ubuntu 24.04 LTS\nLast login: Tue Aug 5 09:12:03 2026\n";
|
||||
|
||||
expect(parseTailscaleCheckBanner(banner)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a lookalike URL on another host", () => {
|
||||
const banner =
|
||||
"# To authenticate, visit: https://login.tailscale.com.evil.example/a/abc123\n";
|
||||
|
||||
expect(parseTailscaleCheckBanner(banner)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty input", () => {
|
||||
expect(parseTailscaleCheckBanner("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTailscaleCheckCompleteBanner", () => {
|
||||
it("recognises the completion banner", () => {
|
||||
expect(
|
||||
isTailscaleCheckCompleteBanner(
|
||||
"# Authentication checked with Tailscale SSH.",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises the completion banner with a time suffix", () => {
|
||||
expect(
|
||||
isTailscaleCheckCompleteBanner(
|
||||
"Authentication checked with Tailscale SSH. Time since last authentication: 0s",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match the check-required banner", () => {
|
||||
expect(
|
||||
isTailscaleCheckCompleteBanner(
|
||||
"# Tailscale SSH requires an additional check.",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match empty input", () => {
|
||||
expect(isTailscaleCheckCompleteBanner("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTmuxAuthBehavior } from "../../../hosts/tmux/auth-utils.js";
|
||||
|
||||
describe("getTmuxAuthBehavior", () => {
|
||||
it("uses credentialless non-interactive authentication for Tailscale SSH", () => {
|
||||
expect(getTmuxAuthBehavior("tailscale")).toEqual({
|
||||
credentialless: true,
|
||||
tryKeyboard: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves keyboard-interactive fallback for none authentication", () => {
|
||||
expect(getTmuxAuthBehavior("none")).toEqual({
|
||||
credentialless: true,
|
||||
tryKeyboard: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat password authentication as credentialless", () => {
|
||||
expect(getTmuxAuthBehavior("password")).toEqual({
|
||||
credentialless: false,
|
||||
tryKeyboard: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { triggerLoginAlert } from "../../utils/alert-trigger.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
describe("triggerLoginAlert", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports a rejected metrics-service request", async () => {
|
||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
||||
} as never);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response('{"error":"Missing authentication token"}', {
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await triggerLoginAlert(7, "user-1", "root", "192.0.2.1");
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Failed to trigger login alert",
|
||||
expect.objectContaining({
|
||||
operation: "login_alert_trigger_error",
|
||||
hostId: 7,
|
||||
error:
|
||||
'Metrics service returned 401: {"error":"Missing authentication token"}',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not log a warning when the metrics service accepts the event", async () => {
|
||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
||||
} as never);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response('{"ok":true}', { status: 200 }),
|
||||
);
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await triggerLoginAlert(7, "user-1", "root", "192.0.2.1");
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the internal auth token and login details the metrics service expects", async () => {
|
||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
||||
} as never);
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
|
||||
|
||||
await triggerLoginAlert(42, "user-1", "root", "10.0.0.5");
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:30005/internal/login-alert",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"x-internal-auth": "internal-token",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
hostId: 42,
|
||||
userId: "user-1",
|
||||
sshUser: "root",
|
||||
fromIp: "10.0.0.5",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs a warning if the fetch itself throws, instead of propagating", async () => {
|
||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
||||
} as never);
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new Error("connect ECONNREFUSED"),
|
||||
);
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
triggerLoginAlert(1, "user-1", "root", "127.0.0.1"),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Failed to trigger login alert",
|
||||
expect.objectContaining({ hostId: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ describe("analytics", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.ENABLE_TELEMETRY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -60,6 +61,7 @@ describe("analytics", () => {
|
||||
});
|
||||
|
||||
it("isAnalyticsEnabled defaults to true via the settings repository", async () => {
|
||||
delete process.env.ENABLE_TELEMETRY;
|
||||
mockGetBoolean.mockResolvedValue(true);
|
||||
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
|
||||
|
||||
@@ -69,6 +71,60 @@ describe("analytics", () => {
|
||||
expect(mockGetBoolean).toHaveBeenCalledWith("analytics_enabled", true);
|
||||
});
|
||||
|
||||
it("ENABLE_TELEMETRY=false disables analytics without consulting the database", async () => {
|
||||
process.env.ENABLE_TELEMETRY = "false";
|
||||
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
|
||||
|
||||
const result = await isAnalyticsEnabled();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockGetBoolean).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ENABLE_TELEMETRY=true forces analytics on without consulting the database", async () => {
|
||||
process.env.ENABLE_TELEMETRY = "TRUE";
|
||||
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
|
||||
|
||||
const result = await isAnalyticsEnabled();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetBoolean).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getTelemetryEnvOverride returns null when unset or blank", async () => {
|
||||
const { getTelemetryEnvOverride } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
delete process.env.ENABLE_TELEMETRY;
|
||||
expect(getTelemetryEnvOverride()).toBe(null);
|
||||
|
||||
process.env.ENABLE_TELEMETRY = " ";
|
||||
expect(getTelemetryEnvOverride()).toBe(null);
|
||||
});
|
||||
|
||||
it("startAnalyticsHeartbeat sends nothing when ENABLE_TELEMETRY=false", async () => {
|
||||
process.env.ENABLE_TELEMETRY = "false";
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
const { startAnalyticsHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
startAnalyticsHeartbeat();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collectAndSendHeartbeat does not call PostHog when ENABLE_TELEMETRY=false", async () => {
|
||||
process.env.ENABLE_TELEMETRY = "false";
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
const { collectAndSendHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getOrCreateInstanceId returns the existing id without generating one", async () => {
|
||||
mockGet.mockResolvedValue("existing-id");
|
||||
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
escapeCsvField,
|
||||
exportFilename,
|
||||
toCsv,
|
||||
toNdjson,
|
||||
} from "../../utils/audit-export.js";
|
||||
import type { AuditLogRecord } from "../../database/repositories/audit-log-repository.js";
|
||||
|
||||
function entry(overrides: Partial<AuditLogRecord> = {}): AuditLogRecord {
|
||||
return {
|
||||
id: 1,
|
||||
userId: "u-1",
|
||||
username: "alice",
|
||||
action: "delete_host",
|
||||
resourceType: "host",
|
||||
resourceId: "9",
|
||||
resourceName: "prod-db",
|
||||
details: null,
|
||||
ipAddress: "203.0.113.9",
|
||||
userAgent: "Mozilla/5.0",
|
||||
success: true,
|
||||
errorMessage: null,
|
||||
timestamp: "2026-07-28 10:00:00",
|
||||
...overrides,
|
||||
} as AuditLogRecord;
|
||||
}
|
||||
|
||||
describe("escapeCsvField", () => {
|
||||
it("leaves plain values alone", () => {
|
||||
expect(escapeCsvField("prod-db")).toBe("prod-db");
|
||||
expect(escapeCsvField(42)).toBe("42");
|
||||
expect(escapeCsvField(true)).toBe("true");
|
||||
});
|
||||
|
||||
it("renders null and undefined as empty", () => {
|
||||
expect(escapeCsvField(null)).toBe("");
|
||||
expect(escapeCsvField(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("quotes and doubles embedded quotes", () => {
|
||||
expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""');
|
||||
});
|
||||
|
||||
it("quotes values containing commas or newlines", () => {
|
||||
expect(escapeCsvField("a,b")).toBe('"a,b"');
|
||||
expect(escapeCsvField("line1\nline2")).toBe('"line1\nline2"');
|
||||
});
|
||||
|
||||
it("neutralises spreadsheet formulas", () => {
|
||||
// An audit entry can carry an attacker-chosen resource name; without this
|
||||
// the exported file executes it when opened.
|
||||
expect(escapeCsvField("=1+1")).toBe("'=1+1");
|
||||
expect(escapeCsvField("+SUM(A1)")).toBe("'+SUM(A1)");
|
||||
expect(escapeCsvField("-2+3")).toBe("'-2+3");
|
||||
expect(escapeCsvField("@import")).toBe("'@import");
|
||||
});
|
||||
|
||||
it("still quotes a formula that also contains a comma", () => {
|
||||
expect(escapeCsvField("=A1,B2")).toBe(`"'=A1,B2"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toCsv", () => {
|
||||
it("writes a header even with no rows", () => {
|
||||
expect(toCsv([])).toBe(
|
||||
"id,timestamp,username,userId,action,resourceType,resourceId,resourceName,success,ipAddress,userAgent,errorMessage,details\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes one line per entry in column order", () => {
|
||||
const lines = toCsv([entry(), entry({ id: 2, username: "bob" })])
|
||||
.trim()
|
||||
.split("\n");
|
||||
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(
|
||||
lines[1].startsWith("1,2026-07-28 10:00:00,alice,u-1,delete_host"),
|
||||
).toBe(true);
|
||||
expect(lines[2].startsWith("2,")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a detached entry readable", () => {
|
||||
const line = toCsv([entry({ userId: null })])
|
||||
.trim()
|
||||
.split("\n")[1];
|
||||
|
||||
// username survives so the row still names who acted.
|
||||
expect(line).toContain("alice");
|
||||
expect(line.split(",")[3]).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toNdjson", () => {
|
||||
it("emits one parseable object per line", () => {
|
||||
const out = toNdjson([entry(), entry({ id: 2 })]);
|
||||
const parsed = out
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
expect(parsed).toHaveLength(2);
|
||||
expect(parsed[0].action).toBe("delete_host");
|
||||
expect(parsed[1].id).toBe(2);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty set", () => {
|
||||
expect(toNdjson([])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportFilename", () => {
|
||||
it("is filesystem-safe and carries the timestamp", () => {
|
||||
const name = exportFilename("csv", new Date("2026-07-28T10:11:12.000Z"));
|
||||
|
||||
expect(name).toBe("termix-audit-2026-07-28-10-11-12.csv");
|
||||
expect(name).not.toMatch(/[:\s]/);
|
||||
});
|
||||
|
||||
it("uses the ndjson extension for the streaming format", () => {
|
||||
expect(exportFilename("ndjson", new Date("2026-07-28T10:11:12.000Z"))).toBe(
|
||||
"termix-audit-2026-07-28-10-11-12.ndjson",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const safeFetch = vi.hoisted(() => vi.fn());
|
||||
const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() }));
|
||||
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: safeFetch,
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({ databaseLogger: logs }));
|
||||
|
||||
const {
|
||||
auditForwardTarget,
|
||||
forwardAuditEntry,
|
||||
forwardPayload,
|
||||
resetAuditForwarderState,
|
||||
AUDIT_FORWARD_URL_ENV,
|
||||
AUDIT_FORWARD_TOKEN_ENV,
|
||||
} = await import("../../utils/audit-forwarder.js");
|
||||
|
||||
const ENTRY = {
|
||||
userId: "u-1",
|
||||
username: "alice",
|
||||
action: "delete_host",
|
||||
resourceType: "host",
|
||||
resourceId: "9",
|
||||
success: true,
|
||||
ipAddress: "203.0.113.9",
|
||||
};
|
||||
|
||||
const NOW = new Date("2026-07-28T10:00:00.000Z");
|
||||
|
||||
beforeEach(() => {
|
||||
safeFetch.mockReset();
|
||||
logs.info.mockReset();
|
||||
logs.warn.mockReset();
|
||||
resetAuditForwarderState();
|
||||
});
|
||||
|
||||
describe("auditForwardTarget", () => {
|
||||
it("is off unless a URL is configured", () => {
|
||||
expect(auditForwardTarget({})).toBeNull();
|
||||
expect(auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: " " })).toBeNull();
|
||||
});
|
||||
|
||||
it("carries an optional bearer token", () => {
|
||||
expect(
|
||||
auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest" }),
|
||||
).toEqual({ url: "https://siem/ingest" });
|
||||
|
||||
expect(
|
||||
auditForwardTarget({
|
||||
[AUDIT_FORWARD_URL_ENV]: "https://siem/ingest",
|
||||
[AUDIT_FORWARD_TOKEN_ENV]: "secret",
|
||||
}),
|
||||
).toEqual({ url: "https://siem/ingest", token: "secret" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("forwardPayload", () => {
|
||||
it("matches the export shape, with absent fields as null", () => {
|
||||
expect(forwardPayload(ENTRY, NOW)).toEqual({
|
||||
timestamp: "2026-07-28T10:00:00.000Z",
|
||||
userId: "u-1",
|
||||
username: "alice",
|
||||
action: "delete_host",
|
||||
resourceType: "host",
|
||||
resourceId: "9",
|
||||
resourceName: null,
|
||||
success: true,
|
||||
ipAddress: "203.0.113.9",
|
||||
userAgent: null,
|
||||
errorMessage: null,
|
||||
details: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("forwardAuditEntry", () => {
|
||||
const env = { [AUDIT_FORWARD_URL_ENV]: "https://siem.example/ingest" };
|
||||
|
||||
it("does nothing when forwarding is not configured", async () => {
|
||||
await expect(forwardAuditEntry(ENTRY, NOW, {})).resolves.toBe(false);
|
||||
expect(safeFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts one NDJSON line through the SSRF-checked fetch", async () => {
|
||||
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(true);
|
||||
|
||||
const [url, init] = safeFetch.mock.calls[0];
|
||||
expect(url).toBe("https://siem.example/ingest");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Content-Type"]).toBe("application/x-ndjson");
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
expect(JSON.parse(init.body.trim()).action).toBe("delete_host");
|
||||
expect(init.body.endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("sends the bearer token when one is set", async () => {
|
||||
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
await forwardAuditEntry(ENTRY, NOW, {
|
||||
...env,
|
||||
[AUDIT_FORWARD_TOKEN_ENV]: "secret",
|
||||
});
|
||||
|
||||
expect(safeFetch.mock.calls[0][1].headers.Authorization).toBe(
|
||||
"Bearer secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a rejected delivery without throwing", async () => {
|
||||
safeFetch.mockResolvedValueOnce({ ok: false, status: 503 });
|
||||
|
||||
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
|
||||
expect(logs.warn).toHaveBeenCalledWith(
|
||||
"Failed to forward audit entry",
|
||||
expect.objectContaining({ reason: "collector returned 503" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("swallows transport errors — a dead SIEM must not break auditing", async () => {
|
||||
safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
||||
|
||||
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
|
||||
expect(logs.warn).toHaveBeenCalledWith(
|
||||
"Failed to forward audit entry",
|
||||
expect.objectContaining({ reason: "ECONNREFUSED" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stops repeating itself once the collector is persistently down", async () => {
|
||||
safeFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await forwardAuditEntry(ENTRY, NOW, env);
|
||||
}
|
||||
|
||||
// 5 per-entry warnings, then one suppression notice — not 8.
|
||||
const perEntry = logs.warn.mock.calls.filter(
|
||||
(call) => call[0] === "Failed to forward audit entry",
|
||||
);
|
||||
expect(perEntry).toHaveLength(5);
|
||||
expect(
|
||||
logs.warn.mock.calls.some((call) =>
|
||||
String(call[0]).includes("suppressing further messages"),
|
||||
),
|
||||
).toBe(true);
|
||||
// It keeps trying regardless.
|
||||
expect(safeFetch).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it("announces recovery after a suppressed outage", async () => {
|
||||
safeFetch.mockResolvedValue({ ok: false, status: 500 });
|
||||
for (let i = 0; i < 6; i++) await forwardAuditEntry(ENTRY, NOW, env);
|
||||
|
||||
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
await forwardAuditEntry(ENTRY, NOW, env);
|
||||
|
||||
expect(logs.info).toHaveBeenCalledWith(
|
||||
"Audit forwarding recovered",
|
||||
expect.objectContaining({ operation: "audit_forward_recovered" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import {
|
||||
migrateAuditRetention,
|
||||
userDeleteIsDestructive,
|
||||
} from "../../utils/audit-retention-migration.js";
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
/** The pre-migration shape: both tables cascade from users. */
|
||||
function legacyDatabase(): Database.Database {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
);
|
||||
|
||||
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,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE session_recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
access_id INTEGER,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
duration INTEGER,
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
format TEXT NOT NULL DEFAULT 'text',
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username) VALUES ('u-1', 'alice'), ('u-2', 'bob');
|
||||
INSERT INTO ssh_data (id, name) VALUES (1, 'prod-db');
|
||||
|
||||
INSERT INTO audit_logs
|
||||
(user_id, username, action, resource_type, resource_id, success, timestamp)
|
||||
VALUES
|
||||
('u-1', 'alice', 'host.delete', 'host', '1', 1, '2026-07-01 10:00:00'),
|
||||
('u-1', 'alice', 'credential.view', 'credential', '9', 1, '2026-07-02 11:00:00'),
|
||||
('u-2', 'bob', 'host.create', 'host', '2', 1, '2026-07-03 12:00:00');
|
||||
|
||||
INSERT INTO session_recordings
|
||||
(host_id, user_id, started_at, recording_path, protocol, format)
|
||||
VALUES
|
||||
(1, 'u-1', '2026-07-01 10:00:00', '/rec/a.guac', 'ssh', 'text'),
|
||||
(1, 'u-2', '2026-07-03 12:00:00', '/rec/b.guac', 'ssh', 'text');
|
||||
`);
|
||||
return sqlite;
|
||||
}
|
||||
|
||||
describe("audit retention migration", () => {
|
||||
it("detects the destructive shape and reports it fixed afterwards", () => {
|
||||
db = legacyDatabase();
|
||||
|
||||
expect(userDeleteIsDestructive(db, "audit_logs")).toBe(true);
|
||||
expect(userDeleteIsDestructive(db, "session_recordings")).toBe(true);
|
||||
|
||||
expect(migrateAuditRetention(db)).toEqual([
|
||||
"audit_logs",
|
||||
"session_recordings",
|
||||
]);
|
||||
|
||||
expect(userDeleteIsDestructive(db, "audit_logs")).toBe(false);
|
||||
expect(userDeleteIsDestructive(db, "session_recordings")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the audit trail when the user is deleted", () => {
|
||||
db = legacyDatabase();
|
||||
migrateAuditRetention(db);
|
||||
|
||||
db.exec("DELETE FROM users WHERE id = 'u-1'");
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT user_id, username, action FROM audit_logs ORDER BY timestamp",
|
||||
)
|
||||
.all() as { user_id: string | null; username: string; action: string }[];
|
||||
|
||||
expect(rows).toHaveLength(3);
|
||||
// The account is gone, but the record still names who acted.
|
||||
expect(rows[0]).toEqual({
|
||||
user_id: null,
|
||||
username: "alice",
|
||||
action: "host.delete",
|
||||
});
|
||||
expect(rows[2].user_id).toBe("u-2");
|
||||
});
|
||||
|
||||
it("backfills a username onto recordings so they stay attributable", () => {
|
||||
db = legacyDatabase();
|
||||
migrateAuditRetention(db);
|
||||
|
||||
db.exec("DELETE FROM users WHERE id = 'u-1'");
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT user_id, username, recording_path FROM session_recordings ORDER BY started_at",
|
||||
)
|
||||
.all() as { user_id: string | null; username: string | null }[];
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].user_id).toBeNull();
|
||||
expect(rows[0].username).toBe("alice");
|
||||
});
|
||||
|
||||
it("loses no data in the copy", () => {
|
||||
db = legacyDatabase();
|
||||
const before = db
|
||||
.prepare("SELECT * FROM audit_logs ORDER BY id")
|
||||
.all() as Record<string, unknown>[];
|
||||
|
||||
migrateAuditRetention(db);
|
||||
|
||||
const after = db
|
||||
.prepare("SELECT * FROM audit_logs ORDER BY id")
|
||||
.all() as Record<string, unknown>[];
|
||||
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
it("still cascades recordings when their host is deleted", () => {
|
||||
db = legacyDatabase();
|
||||
migrateAuditRetention(db);
|
||||
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
db.exec("DELETE FROM ssh_data WHERE id = 1");
|
||||
|
||||
expect(
|
||||
db.prepare("SELECT COUNT(*) AS n FROM session_recordings").get(),
|
||||
).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it("is idempotent and leaves an already-migrated database alone", () => {
|
||||
db = legacyDatabase();
|
||||
migrateAuditRetention(db);
|
||||
|
||||
const rowsAfterFirst = db.prepare("SELECT * FROM audit_logs").all();
|
||||
expect(migrateAuditRetention(db)).toEqual([]);
|
||||
expect(db.prepare("SELECT * FROM audit_logs").all()).toEqual(
|
||||
rowsAfterFirst,
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing on a database without the tables", () => {
|
||||
db = new Database(":memory:");
|
||||
|
||||
expect(() => migrateAuditRetention(db)).not.toThrow();
|
||||
expect(migrateAuditRetention(db)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const findById = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentAuditLogRepository: () => ({ create: vi.fn() }),
|
||||
createCurrentUserRepository: () => ({ findById }),
|
||||
}));
|
||||
|
||||
const { getAuditUsername, getRequestMeta } =
|
||||
await import("../../utils/audit-logger.js");
|
||||
|
||||
beforeEach(() => findById.mockReset());
|
||||
|
||||
describe("getAuditUsername", () => {
|
||||
it("resolves the username to store alongside the entry", async () => {
|
||||
findById.mockResolvedValueOnce({ id: "u-1", username: "alice" });
|
||||
|
||||
await expect(getAuditUsername("u-1")).resolves.toBe("alice");
|
||||
});
|
||||
|
||||
it("falls back to the id for an account that no longer exists", async () => {
|
||||
findById.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(getAuditUsername("u-gone")).resolves.toBe("u-gone");
|
||||
});
|
||||
|
||||
it("never throws, so it cannot break the operation being audited", async () => {
|
||||
findById.mockRejectedValueOnce(new Error("database unavailable"));
|
||||
|
||||
await expect(getAuditUsername("u-1")).resolves.toBe("u-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRequestMeta", () => {
|
||||
it("prefers the first x-forwarded-for hop", () => {
|
||||
const meta = getRequestMeta({
|
||||
headers: {
|
||||
"x-forwarded-for": "203.0.113.9, 10.0.0.1",
|
||||
"user-agent": "Mozilla/5.0",
|
||||
},
|
||||
ip: "10.0.0.1",
|
||||
} as never);
|
||||
|
||||
expect(meta).toEqual({
|
||||
ipAddress: "203.0.113.9",
|
||||
userAgent: "Mozilla/5.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the socket address", () => {
|
||||
const meta = getRequestMeta({ headers: {}, ip: "192.0.2.5" } as never);
|
||||
|
||||
expect(meta.ipAddress).toBe("192.0.2.5");
|
||||
expect(meta.userAgent).toBe("");
|
||||
});
|
||||
});
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
sqlite: null as unknown as Database.Database,
|
||||
settings: new Map<string, string>(),
|
||||
resyncedHostIds: [] as number[],
|
||||
saves: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings.set(key, value);
|
||||
},
|
||||
}),
|
||||
getCurrentRepositorySqlite: () => state.sqlite,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({
|
||||
resyncHost: async (hostId: number) => {
|
||||
state.resyncedHostIds.push(hostId);
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
forceSave: async (reason: string) => {
|
||||
state.saves.push(reason);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { runLegacySharedSshAuthOptInMigration } from "../../../utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.js";
|
||||
|
||||
beforeEach(() => {
|
||||
state.sqlite = new Database(":memory:");
|
||||
state.sqlite.exec(`
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY,
|
||||
share_ssh_auth INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE host_access (
|
||||
id INTEGER PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL,
|
||||
expires_at TEXT
|
||||
);
|
||||
CREATE TABLE shared_host_secrets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
protocol TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO ssh_data (id, share_ssh_auth)
|
||||
VALUES (1, 0), (2, 0), (3, 1), (4, 0), (5, 0);
|
||||
INSERT INTO host_access (id, host_id, expires_at)
|
||||
VALUES
|
||||
(10, 1, NULL),
|
||||
(30, 3, NULL),
|
||||
(40, 4, NULL),
|
||||
(50, 5, '2000-01-01T00:00:00.000Z');
|
||||
INSERT INTO shared_host_secrets (id, host_access_id, protocol)
|
||||
VALUES
|
||||
(100, 10, 'ssh'),
|
||||
(400, 40, 'rdp'),
|
||||
(500, 50, 'ssh');
|
||||
`);
|
||||
state.settings.clear();
|
||||
state.resyncedHostIds = [];
|
||||
state.saves = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.sqlite.close();
|
||||
});
|
||||
|
||||
describe("runLegacySharedSshAuthOptInMigration", () => {
|
||||
it("preserves preexisting sharing while leaving unshared hosts private", async () => {
|
||||
await expect(runLegacySharedSshAuthOptInMigration()).resolves.toEqual({
|
||||
enabled: 1,
|
||||
resynced: 2,
|
||||
skipped: 0,
|
||||
});
|
||||
|
||||
expect(
|
||||
state.sqlite
|
||||
.prepare("SELECT id, share_ssh_auth FROM ssh_data ORDER BY id")
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ id: 1, share_ssh_auth: 1 },
|
||||
{ id: 2, share_ssh_auth: 0 },
|
||||
{ id: 3, share_ssh_auth: 1 },
|
||||
{ id: 4, share_ssh_auth: 0 },
|
||||
{ id: 5, share_ssh_auth: 0 },
|
||||
]);
|
||||
expect(state.resyncedHostIds).toEqual([1, 3]);
|
||||
expect(state.settings.get("legacy_shared_ssh_auth_opt_in_v1")).toBe("done");
|
||||
expect(state.saves).toEqual(["legacy_shared_ssh_auth_opt_in_migration"]);
|
||||
});
|
||||
|
||||
it("recognizes a legacy SSH credential snapshot as prior sharing evidence", async () => {
|
||||
state.sqlite.exec(`
|
||||
CREATE TABLE shared_credentials (
|
||||
id INTEGER PRIMARY KEY,
|
||||
host_access_id INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO shared_credentials (id, host_access_id) VALUES (1, 40);
|
||||
`);
|
||||
|
||||
await expect(runLegacySharedSshAuthOptInMigration()).resolves.toEqual({
|
||||
enabled: 2,
|
||||
resynced: 3,
|
||||
skipped: 0,
|
||||
});
|
||||
|
||||
expect(
|
||||
state.sqlite
|
||||
.prepare("SELECT share_ssh_auth FROM ssh_data WHERE id = 4")
|
||||
.get(),
|
||||
).toEqual({ share_ssh_auth: 1 });
|
||||
expect(state.resyncedHostIds).toEqual([1, 3, 4]);
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
await runLegacySharedSshAuthOptInMigration();
|
||||
state.resyncedHostIds = [];
|
||||
state.saves = [];
|
||||
|
||||
expect(await runLegacySharedSshAuthOptInMigration()).toBeNull();
|
||||
expect(state.resyncedHostIds).toEqual([]);
|
||||
expect(state.saves).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not re-share private hosts after the privacy migration has run", async () => {
|
||||
state.settings.set("private_shared_ssh_auth_v1", "done");
|
||||
|
||||
await expect(runLegacySharedSshAuthOptInMigration()).resolves.toEqual({
|
||||
enabled: 0,
|
||||
resynced: 1,
|
||||
skipped: 0,
|
||||
});
|
||||
|
||||
expect(
|
||||
state.sqlite
|
||||
.prepare("SELECT share_ssh_auth FROM ssh_data WHERE id = 1")
|
||||
.get(),
|
||||
).toEqual({ share_ssh_auth: 0 });
|
||||
expect(state.resyncedHostIds).toEqual([3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
sqlite: null as unknown as Database.Database,
|
||||
settings: new Map<string, string>(),
|
||||
saves: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings.set(key, value);
|
||||
},
|
||||
}),
|
||||
getCurrentRepositorySqlite: () => state.sqlite,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
forceSave: async (reason: string) => {
|
||||
state.saves.push(reason);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { runPrivateSharedSshAuthMigration } from "../../../utils/crypto-migration/private-shared-ssh-auth-migration.js";
|
||||
|
||||
beforeEach(() => {
|
||||
state.sqlite = new Database(":memory:");
|
||||
state.sqlite.exec(`
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY,
|
||||
share_ssh_auth INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE host_access (
|
||||
id INTEGER PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE shared_host_secrets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
protocol TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO ssh_data (id, share_ssh_auth)
|
||||
VALUES (1, 1), (2, 0);
|
||||
INSERT INTO host_access (id, host_id)
|
||||
VALUES (10, 1), (20, 2);
|
||||
INSERT INTO shared_host_secrets (id, host_access_id, protocol)
|
||||
VALUES
|
||||
(1, 10, 'ssh'),
|
||||
(2, 10, 'rdp'),
|
||||
(3, 20, 'ssh'),
|
||||
(4, 20, 'vnc');
|
||||
`);
|
||||
state.settings.clear();
|
||||
state.saves = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.sqlite.close();
|
||||
});
|
||||
|
||||
describe("runPrivateSharedSshAuthMigration", () => {
|
||||
it("preserves opted-in SSH snapshots and removes only private ones", async () => {
|
||||
expect(await runPrivateSharedSshAuthMigration()).toBe(1);
|
||||
expect(
|
||||
state.sqlite
|
||||
.prepare(
|
||||
"SELECT host_access_id, protocol FROM shared_host_secrets ORDER BY id",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ host_access_id: 10, protocol: "ssh" },
|
||||
{ host_access_id: 10, protocol: "rdp" },
|
||||
{ host_access_id: 20, protocol: "vnc" },
|
||||
]);
|
||||
expect(state.settings.get("private_shared_ssh_auth_v1")).toBe("done");
|
||||
expect(state.saves).toEqual(["private_shared_ssh_auth_migration"]);
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
state.settings.set("private_shared_ssh_auth_v1", "done");
|
||||
|
||||
expect(await runPrivateSharedSshAuthMigration()).toBeNull();
|
||||
expect(
|
||||
state.sqlite
|
||||
.prepare("SELECT COUNT(*) AS count FROM shared_host_secrets")
|
||||
.get(),
|
||||
).toEqual({ count: 4 });
|
||||
expect(state.saves).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import {
|
||||
ALLOW_EMPTY_DATA_DIR_ENV,
|
||||
assertDataDirIsNotMisconfigured,
|
||||
DataDirMisconfiguredError,
|
||||
findDatabaseOutsideDataDir,
|
||||
} from "../../utils/data-dir-guard.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-datadir-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Writes a plain (unencrypted) database file into `dir`. */
|
||||
function writePlainDatabase(dir: string, size = 4096): string {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const dbPath = path.join(dir, "db.sqlite");
|
||||
fs.writeFileSync(dbPath, Buffer.alloc(size, 1));
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("findDatabaseOutsideDataDir", () => {
|
||||
it("returns null on a genuinely fresh install", () => {
|
||||
const cwd = makeTempDir();
|
||||
const dataDir = path.join(cwd, "db", "data");
|
||||
|
||||
expect(findDatabaseOutsideDataDir(dataDir, cwd)).toBeNull();
|
||||
});
|
||||
|
||||
it("finds a database left in the legacy data directory", () => {
|
||||
const cwd = makeTempDir();
|
||||
const legacyDir = path.join(cwd, "data");
|
||||
writePlainDatabase(legacyDir);
|
||||
|
||||
expect(findDatabaseOutsideDataDir(path.join(cwd, "db", "data"), cwd)).toBe(
|
||||
legacyDir,
|
||||
);
|
||||
});
|
||||
|
||||
it("finds a database under the default directory when DATA_DIR points elsewhere", () => {
|
||||
const cwd = makeTempDir();
|
||||
const defaultDir = path.join(cwd, "db", "data");
|
||||
writePlainDatabase(defaultDir);
|
||||
|
||||
expect(findDatabaseOutsideDataDir("/mnt/unmounted-volume", cwd)).toBe(
|
||||
defaultDir,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores the configured data directory itself", () => {
|
||||
const cwd = makeTempDir();
|
||||
const dataDir = path.join(cwd, "data");
|
||||
writePlainDatabase(dataDir);
|
||||
|
||||
expect(findDatabaseOutsideDataDir(dataDir, cwd)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a zero-length database file", () => {
|
||||
const cwd = makeTempDir();
|
||||
writePlainDatabase(path.join(cwd, "data"), 0);
|
||||
|
||||
expect(findDatabaseOutsideDataDir(path.join(cwd, "db", "data"), cwd)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertDataDirIsNotMisconfigured", () => {
|
||||
it("passes when no database exists anywhere else", () => {
|
||||
const cwd = makeTempDir();
|
||||
|
||||
expect(() =>
|
||||
assertDataDirIsNotMisconfigured(path.join(cwd, "db", "data"), {}, cwd),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("refuses to start and names both directories", () => {
|
||||
const cwd = makeTempDir();
|
||||
const legacyDir = path.join(cwd, "data");
|
||||
writePlainDatabase(legacyDir);
|
||||
const dataDir = path.join(cwd, "db", "data");
|
||||
|
||||
expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow(
|
||||
DataDirMisconfiguredError,
|
||||
);
|
||||
// Matched as substrings, not patterns: Windows paths are full of
|
||||
// backslash sequences a RegExp would read as escapes.
|
||||
expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow(
|
||||
legacyDir,
|
||||
);
|
||||
expect(() => assertDataDirIsNotMisconfigured(dataDir, {}, cwd)).toThrow(
|
||||
dataDir,
|
||||
);
|
||||
});
|
||||
|
||||
it("can be overridden to start with a new database", () => {
|
||||
const cwd = makeTempDir();
|
||||
writePlainDatabase(path.join(cwd, "data"));
|
||||
|
||||
for (const value of ["true", "1", "YES", "on"]) {
|
||||
expect(() =>
|
||||
assertDataDirIsNotMisconfigured(
|
||||
path.join(cwd, "db", "data"),
|
||||
{ [ALLOW_EMPTY_DATA_DIR_ENV]: value },
|
||||
cwd,
|
||||
),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("still refuses when the override is not a truthy value", () => {
|
||||
const cwd = makeTempDir();
|
||||
writePlainDatabase(path.join(cwd, "data"));
|
||||
|
||||
expect(() =>
|
||||
assertDataDirIsNotMisconfigured(
|
||||
path.join(cwd, "db", "data"),
|
||||
{ [ALLOW_EMPTY_DATA_DIR_ENV]: "false" },
|
||||
cwd,
|
||||
),
|
||||
).toThrow(DataDirMisconfiguredError);
|
||||
});
|
||||
});
|
||||
@@ -38,4 +38,28 @@ describe("DatabaseSaveTrigger", () => {
|
||||
expect(DatabaseSaveTrigger.isDirty).toBe(false);
|
||||
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
|
||||
});
|
||||
|
||||
it("queues a force save behind an in-flight save", async () => {
|
||||
let finishFirstSave: (() => void) | undefined;
|
||||
const firstSave = new Promise<void>((resolve) => {
|
||||
finishFirstSave = resolve;
|
||||
});
|
||||
const save = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockReturnValueOnce(firstSave)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
DatabaseSaveTrigger.initialize(save);
|
||||
|
||||
const first = DatabaseSaveTrigger.forceSave("first_write");
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
||||
|
||||
const second = DatabaseSaveTrigger.forceSave("sso_provider_write");
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
finishFirstSave?.();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LookupAddress, LookupAllOptions } from "dns";
|
||||
import type { LookupAddress, LookupAllOptions, LookupOptions } from "dns";
|
||||
import {
|
||||
createDnsLookupHook,
|
||||
isBlockedAddress,
|
||||
@@ -53,55 +53,133 @@ describe("isBlockedAddress", () => {
|
||||
// private — and testing it through a real Agent/fetch call would only
|
||||
// add flakiness (real TCP connects, undici's own quirks) without adding
|
||||
// coverage of the logic that actually broke.
|
||||
//
|
||||
// `lookupOptions.all` controls the *caller's* expected callback shape
|
||||
// (single address vs. full array) — this is the flag Node's happy-eyeballs
|
||||
// autoSelectFamily sets to `true`. It's independent of the internal call to
|
||||
// the underlying resolver, which the hook always forces to `all: true` so it
|
||||
// has every candidate address available to run the blocklist check against.
|
||||
function runHook(
|
||||
addresses: LookupAddress[],
|
||||
error: NodeJS.ErrnoException | null = null,
|
||||
lookupOptions: LookupOptions = { all: true },
|
||||
) {
|
||||
const fakeLookup = (
|
||||
_host: string,
|
||||
_opts: LookupAllOptions,
|
||||
cb: (err: NodeJS.ErrnoException | null, addrs: LookupAddress[]) => void,
|
||||
) => cb(error, addresses);
|
||||
const fakeLookup = vi.fn(
|
||||
(
|
||||
_host: string,
|
||||
_opts: LookupAllOptions,
|
||||
cb: (err: NodeJS.ErrnoException | null, addrs: LookupAddress[]) => void,
|
||||
) => cb(error, addresses),
|
||||
);
|
||||
|
||||
const hook = createDnsLookupHook(fakeLookup);
|
||||
const callback = vi.fn();
|
||||
hook("example.invalid", { all: true }, callback);
|
||||
return callback;
|
||||
hook("example.invalid", lookupOptions, callback);
|
||||
return { callback, fakeLookup };
|
||||
}
|
||||
|
||||
// The three lookupOptions shapes a real caller can pass, and the tail args
|
||||
// (everything after the leading null/error arg) the hook must answer with
|
||||
// for each — [] for the array form Node's autoSelectFamily expects, ["", 0]
|
||||
// for the legacy single-address form. Reused as plain data across the
|
||||
// it.each tables below, matching the flat tuple style used elsewhere in
|
||||
// this test suite (see termix-id-keys.test.ts, oidc-desktop-callback.test.ts)
|
||||
// rather than nesting a parameterized describe block.
|
||||
const lookupOptionsCases: Array<[string, LookupOptions, unknown[]]> = [
|
||||
["all:true (Node's autoSelectFamily/happy-eyeballs)", { all: true }, [[]]],
|
||||
["all:false (legacy)", { all: false } as LookupOptions, ["", 0]],
|
||||
["all omitted (legacy)", {} as LookupOptions, ["", 0]],
|
||||
];
|
||||
|
||||
// Fixed answer used by the success table below — kept separate from
|
||||
// lookupOptionsCases because the expected tail args here are the resolved
|
||||
// address(es) themselves, not a fixed "", 0 vs [] shape.
|
||||
const publicAddresses = [
|
||||
{ address: "104.21.52.150", family: 4 },
|
||||
{ address: "2606:4700:3034::ac43:c88d", family: 6 },
|
||||
];
|
||||
const successCases: Array<[string, LookupOptions, unknown[]]> = [
|
||||
[
|
||||
"all:true (Node's autoSelectFamily/happy-eyeballs)",
|
||||
{ all: true },
|
||||
[publicAddresses],
|
||||
],
|
||||
[
|
||||
"all:false (legacy)",
|
||||
{ all: false } as LookupOptions,
|
||||
[publicAddresses[0].address, publicAddresses[0].family],
|
||||
],
|
||||
[
|
||||
"all omitted (legacy)",
|
||||
{} as LookupOptions,
|
||||
[publicAddresses[0].address, publicAddresses[0].family],
|
||||
],
|
||||
];
|
||||
|
||||
describe("createDnsLookupHook", () => {
|
||||
it("allows a public IPv4 address through", () => {
|
||||
const callback = runHook([{ address: "104.21.52.150", family: 4 }]);
|
||||
expect(callback).toHaveBeenCalledWith(null, "104.21.52.150", 4);
|
||||
});
|
||||
it.each(successCases)(
|
||||
"returns the resolved address(es) on a fully public answer (%s)",
|
||||
(_label, lookupOptions, tailArgs) => {
|
||||
const { callback } = runHook(publicAddresses, null, lookupOptions);
|
||||
expect(callback).toHaveBeenCalledWith(null, ...tailArgs);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a private address with the private-destination error", () => {
|
||||
const callback = runHook([{ address: "192.168.1.1", family: 4 }]);
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Private destinations are not allowed",
|
||||
}),
|
||||
"",
|
||||
0,
|
||||
it.each(lookupOptionsCases)(
|
||||
"rejects if any address is private, including an IPv4-mapped IPv6 spoof not in first position (%s)",
|
||||
(_label, lookupOptions, tailArgs) => {
|
||||
const { callback } = runHook(
|
||||
[
|
||||
{ address: "104.21.52.150", family: 4 },
|
||||
{ address: "::ffff:192.168.1.1", family: 6 },
|
||||
{ address: "2606:4700:3034::ac43:c88d", family: 6 },
|
||||
],
|
||||
null,
|
||||
lookupOptions,
|
||||
);
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Private destinations are not allowed",
|
||||
}),
|
||||
...tailArgs,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lookupOptionsCases)(
|
||||
"rejects with a distinct error when DNS returns no addresses (%s)",
|
||||
(_label, lookupOptions, tailArgs) => {
|
||||
const { callback } = runHook([], null, lookupOptions);
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DNS resolution returned no addresses",
|
||||
}),
|
||||
...tailArgs,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lookupOptionsCases)(
|
||||
"propagates a real DNS lookup error untouched (%s)",
|
||||
(_label, lookupOptions, tailArgs) => {
|
||||
const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND"), {
|
||||
code: "ENOTFOUND",
|
||||
});
|
||||
const { callback } = runHook([], dnsError, lookupOptions);
|
||||
expect(callback).toHaveBeenCalledWith(dnsError, ...tailArgs);
|
||||
},
|
||||
);
|
||||
|
||||
it("always asks the underlying resolver for all:true regardless of the caller's option", () => {
|
||||
const { fakeLookup } = runHook(
|
||||
[{ address: "104.21.52.150", family: 4 }],
|
||||
null,
|
||||
{ all: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with a distinct error when DNS returns no addresses", () => {
|
||||
const callback = runHook([]);
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "DNS resolution returned no addresses",
|
||||
}),
|
||||
"",
|
||||
0,
|
||||
expect(fakeLookup).toHaveBeenCalledWith(
|
||||
"example.invalid",
|
||||
expect.objectContaining({ all: true, verbatim: true }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates a real DNS lookup error untouched", () => {
|
||||
const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND"), {
|
||||
code: "ENOTFOUND",
|
||||
});
|
||||
const callback = runHook([], dnsError);
|
||||
expect(callback).toHaveBeenCalledWith(dnsError, "", 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureSharedHostAuthOverrideProtocolSchema,
|
||||
migrateLegacySharedHostAuthOverrides,
|
||||
} from "../../utils/shared-host-auth-override-migration.js";
|
||||
|
||||
describe("migrateLegacySharedHostAuthOverrides", () => {
|
||||
let sqlite: Database.Database | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
sqlite?.close();
|
||||
sqlite = null;
|
||||
});
|
||||
|
||||
it("creates protocol-aware storage with SSH as the default", () => {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE ssh_credentials (id INTEGER PRIMARY KEY);
|
||||
INSERT INTO users (id) VALUES ('recipient');
|
||||
INSERT INTO ssh_data (id) VALUES (42);
|
||||
INSERT INTO ssh_credentials (id) VALUES (7);
|
||||
`);
|
||||
|
||||
expect(ensureSharedHostAuthOverrideProtocolSchema(sqlite)).toBe("created");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO shared_host_auth_overrides (host_id, user_id, credential_id) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(42, "recipient", 7);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT protocol, credential_id FROM shared_host_auth_overrides",
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ protocol: "ssh", credential_id: 7 });
|
||||
expect(ensureSharedHostAuthOverrideProtocolSchema(sqlite)).toBe("current");
|
||||
});
|
||||
|
||||
it("moves direct-share overrides once and clears the legacy column", () => {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE host_access (
|
||||
id INTEGER PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
override_credential_id INTEGER
|
||||
);
|
||||
CREATE TABLE shared_host_auth_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
credential_id INTEGER NOT NULL,
|
||||
UNIQUE(host_id, user_id, protocol)
|
||||
);
|
||||
INSERT INTO host_access
|
||||
(id, host_id, user_id, role_id, override_credential_id)
|
||||
VALUES
|
||||
(1, 42, 'direct-user', NULL, 7),
|
||||
(2, 42, NULL, 3, 8),
|
||||
(3, 43, 'no-override', NULL, NULL);
|
||||
`);
|
||||
const settings = new Map<string, string>();
|
||||
|
||||
expect(
|
||||
migrateLegacySharedHostAuthOverrides(
|
||||
sqlite,
|
||||
(key) => settings.get(key) ?? null,
|
||||
(key, value) => settings.set(key, value),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT host_id, user_id, protocol, credential_id FROM shared_host_auth_overrides",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
host_id: 42,
|
||||
user_id: "direct-user",
|
||||
protocol: "ssh",
|
||||
credential_id: 7,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT override_credential_id FROM host_access WHERE id = 1")
|
||||
.get(),
|
||||
).toEqual({ override_credential_id: null });
|
||||
|
||||
sqlite
|
||||
.prepare("UPDATE host_access SET override_credential_id = 9 WHERE id = 1")
|
||||
.run();
|
||||
expect(
|
||||
migrateLegacySharedHostAuthOverrides(
|
||||
sqlite,
|
||||
(key) => settings.get(key) ?? null,
|
||||
(key, value) => settings.set(key, value),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT credential_id FROM shared_host_auth_overrides WHERE host_id = 42",
|
||||
)
|
||||
.get(),
|
||||
).toEqual({ credential_id: 7 });
|
||||
});
|
||||
|
||||
it("preserves pre-protocol rows as SSH and permits protocol isolation", () => {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE ssh_credentials (id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE shared_host_auth_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
credential_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(host_id, user_id)
|
||||
);
|
||||
INSERT INTO users (id) VALUES ('recipient');
|
||||
INSERT INTO ssh_data (id) VALUES (42);
|
||||
INSERT INTO ssh_credentials (id) VALUES (7), (8);
|
||||
INSERT INTO shared_host_auth_overrides
|
||||
(host_id, user_id, credential_id)
|
||||
VALUES (42, 'recipient', 7);
|
||||
`);
|
||||
|
||||
expect(ensureSharedHostAuthOverrideProtocolSchema(sqlite)).toBe("migrated");
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT protocol, credential_id FROM shared_host_auth_overrides WHERE host_id = 42",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([{ protocol: "ssh", credential_id: 7 }]);
|
||||
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO shared_host_auth_overrides (host_id, user_id, protocol, credential_id) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run(42, "recipient", "rdp", 8);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT protocol, credential_id FROM shared_host_auth_overrides ORDER BY protocol",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ protocol: "rdp", credential_id: 8 },
|
||||
{ protocol: "ssh", credential_id: 7 },
|
||||
]);
|
||||
expect(ensureSharedHostAuthOverrideProtocolSchema(sqlite)).toBe("current");
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const ownerDEK = crypto.randomBytes(32);
|
||||
const targetDEK = crypto.randomBytes(32);
|
||||
|
||||
type SecretRow = Record<string, unknown> & {
|
||||
id: number;
|
||||
hostAccessId: number;
|
||||
targetUserId: string;
|
||||
protocol: string;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
hosts: new Map<number, Record<string, unknown>>(),
|
||||
credentials: new Map<number, Record<string, unknown>>(),
|
||||
@@ -142,6 +135,7 @@ function baseHost(overrides: Record<string, unknown> = {}) {
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
credentialId: null,
|
||||
shareSshAuth: false,
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
@@ -173,26 +167,29 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("SharedHostSecretsManager", () => {
|
||||
it("snapshots an inline-password SSH host and the target can decrypt it", async () => {
|
||||
it("keeps an inline-password SSH host private by default", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
expect(state.secretRows).toHaveLength(1);
|
||||
const row = state.secretRows[0];
|
||||
expect(row.protocol).toBe("ssh");
|
||||
expect(row.sourceType).toBe("inline");
|
||||
expect(row.encryptedPassword).not.toBe("hunter2");
|
||||
expect(state.secretRows).toHaveLength(0);
|
||||
expect(await manager.getSecretForUser(42, "target", "ssh")).toBeNull();
|
||||
});
|
||||
|
||||
const secret = await manager.getSecretForUser(42, "target", "ssh");
|
||||
expect(secret).toMatchObject({
|
||||
it("snapshots inline SSH authentication when the owner opts in", async () => {
|
||||
state.hosts.set(42, baseHost({ shareSshAuth: true }));
|
||||
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
|
||||
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "hunter2",
|
||||
});
|
||||
});
|
||||
|
||||
it("snapshots every enabled protocol from credential and inline sources", async () => {
|
||||
it("snapshots opted-in SSH credential auth alongside enabled non-SSH protocols", async () => {
|
||||
state.credentials.set(123, {
|
||||
id: 123,
|
||||
userId: "owner",
|
||||
@@ -209,6 +206,7 @@ describe("SharedHostSecretsManager", () => {
|
||||
baseHost({
|
||||
authType: "credential",
|
||||
credentialId: 123,
|
||||
shareSshAuth: true,
|
||||
password: null,
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
@@ -228,8 +226,7 @@ describe("SharedHostSecretsManager", () => {
|
||||
"telnet",
|
||||
]);
|
||||
|
||||
const ssh = await manager.getSecretForUser(42, "target", "ssh");
|
||||
expect(ssh).toMatchObject({
|
||||
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
|
||||
username: "cred-user",
|
||||
authType: "key",
|
||||
key: "PRIVATE-KEY",
|
||||
@@ -253,28 +250,28 @@ describe("SharedHostSecretsManager", () => {
|
||||
});
|
||||
|
||||
it("produces no snapshot rows for secret-less auth types", async () => {
|
||||
state.hosts.set(42, baseHost({ authType: "opkssh", password: null }));
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
authType: "opkssh",
|
||||
password: null,
|
||||
shareSshAuth: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
expect(state.secretRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes stale protocol rows on re-snapshot", async () => {
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rdp-pass",
|
||||
}),
|
||||
);
|
||||
it("removes the SSH snapshot when the owner disables sharing", async () => {
|
||||
state.hosts.set(42, baseHost({ shareSshAuth: true }));
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
expect(state.secretRows).toHaveLength(2);
|
||||
expect(state.secretRows).toHaveLength(1);
|
||||
|
||||
// Owner turns RDP off; the RDP snapshot must disappear.
|
||||
// Owner makes SSH authentication private again.
|
||||
state.hosts.set(42, baseHost());
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
|
||||
expect(state.secretRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fails fast when a participant has no DEK", async () => {
|
||||
@@ -286,7 +283,14 @@ describe("SharedHostSecretsManager", () => {
|
||||
});
|
||||
|
||||
it("cannot be decrypted with the wrong DEK", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rdp-pass",
|
||||
}),
|
||||
);
|
||||
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||
|
||||
const row = state.secretRows[0];
|
||||
@@ -294,14 +298,21 @@ describe("SharedHostSecretsManager", () => {
|
||||
FieldCrypto.decryptField(
|
||||
row.encryptedPassword as string,
|
||||
ownerDEK,
|
||||
"shared-7-target-ssh",
|
||||
"shared-7-target-rdp",
|
||||
"password",
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("resyncHost re-snapshots direct grants and role members", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rdp-pass",
|
||||
}),
|
||||
);
|
||||
state.accessToHost = new Map([
|
||||
[1, 42],
|
||||
[2, 42],
|
||||
@@ -322,16 +333,30 @@ describe("SharedHostSecretsManager", () => {
|
||||
[2, "member-1"],
|
||||
]);
|
||||
|
||||
// Owner rotates the inline password; resync updates the copies.
|
||||
state.hosts.set(42, baseHost({ password: "rotated" }));
|
||||
// Owner rotates the non-SSH password; resync updates those copies.
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rotated",
|
||||
}),
|
||||
);
|
||||
await manager.resyncHost(42);
|
||||
|
||||
const secret = await manager.getSecretForUser(42, "target", "ssh");
|
||||
const secret = await manager.getSecretForUser(42, "target", "rdp");
|
||||
expect(secret?.password).toBe("rotated");
|
||||
});
|
||||
|
||||
it("snapshotForRoleMember fans out from role grants", async () => {
|
||||
state.hosts.set(42, baseHost());
|
||||
state.hosts.set(
|
||||
42,
|
||||
baseHost({
|
||||
enableRdp: true,
|
||||
rdpUser: "rdp-admin",
|
||||
rdpPassword: "rdp-pass",
|
||||
}),
|
||||
);
|
||||
state.accessToHost = new Map([[2, 42]]);
|
||||
state.grants = [{ id: 2, hostId: 42, userId: null, roleId: 9 }];
|
||||
|
||||
@@ -341,7 +366,7 @@ describe("SharedHostSecretsManager", () => {
|
||||
expect(state.secretRows[0]).toMatchObject({
|
||||
hostAccessId: 2,
|
||||
targetUserId: "member-1",
|
||||
protocol: "ssh",
|
||||
protocol: "rdp",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import crypto from "crypto";
|
||||
|
||||
const systemKey = crypto.randomBytes(32);
|
||||
const getEncryptionKey = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: { getInstance: () => ({ getEncryptionKey }) },
|
||||
}));
|
||||
|
||||
const {
|
||||
decryptSsoConfigSecrets,
|
||||
decryptSystemSecret,
|
||||
encryptSsoConfigSecrets,
|
||||
encryptSystemSecret,
|
||||
isSystemEncrypted,
|
||||
SSO_SECRET_FIELDS,
|
||||
} = await import("../../utils/system-secret-crypto.js");
|
||||
|
||||
beforeEach(() => {
|
||||
getEncryptionKey.mockReset();
|
||||
getEncryptionKey.mockResolvedValue(systemKey);
|
||||
});
|
||||
|
||||
describe("system secret encryption", () => {
|
||||
it("round-trips a secret", async () => {
|
||||
const sealed = await encryptSystemSecret("s3cr3t-client-secret");
|
||||
|
||||
expect(sealed).not.toContain("s3cr3t");
|
||||
expect(isSystemEncrypted(sealed)).toBe(true);
|
||||
await expect(decryptSystemSecret(sealed)).resolves.toBe(
|
||||
"s3cr3t-client-secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("produces a different ciphertext each time", async () => {
|
||||
const a = await encryptSystemSecret("same");
|
||||
const b = await encryptSystemSecret("same");
|
||||
|
||||
// Random IV per call, so identical secrets are not identifiable.
|
||||
expect(a).not.toBe(b);
|
||||
await expect(decryptSystemSecret(a)).resolves.toBe("same");
|
||||
await expect(decryptSystemSecret(b)).resolves.toBe("same");
|
||||
});
|
||||
|
||||
it("does not double-encrypt an already sealed value", async () => {
|
||||
const once = await encryptSystemSecret("value");
|
||||
const twice = await encryptSystemSecret(once);
|
||||
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
it("leaves empty values alone", async () => {
|
||||
await expect(encryptSystemSecret("")).resolves.toBe("");
|
||||
await expect(decryptSystemSecret("")).resolves.toBe("");
|
||||
});
|
||||
|
||||
it("detects tampering", async () => {
|
||||
const sealed = await encryptSystemSecret("value");
|
||||
const parts = sealed.replace("sysenc:v1:", "").split(":");
|
||||
const flipped = Buffer.from(parts[2], "base64");
|
||||
flipped[0] ^= 0xff;
|
||||
const tampered = `sysenc:v1:${parts[0]}:${parts[1]}:${flipped.toString("base64")}`;
|
||||
|
||||
// GCM auth tag must reject a modified payload rather than return garbage.
|
||||
await expect(decryptSystemSecret(tampered)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a malformed sealed value", async () => {
|
||||
await expect(
|
||||
decryptSystemSecret("sysenc:v1:only-one-part"),
|
||||
).rejects.toThrow(/Malformed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy compatibility", () => {
|
||||
it("decodes values written by the old base64 scheme", async () => {
|
||||
const legacy = `encoded:${Buffer.from("old-secret").toString("base64")}`;
|
||||
|
||||
// Must keep working: an existing install cannot be locked out of SSO login
|
||||
// just because the storage format changed.
|
||||
await expect(decryptSystemSecret(legacy)).resolves.toBe("old-secret");
|
||||
});
|
||||
|
||||
it("decodes the mislabelled 'encrypted:' variant too", async () => {
|
||||
const legacy = `encrypted:${Buffer.from("old-secret").toString("base64")}`;
|
||||
|
||||
await expect(decryptSystemSecret(legacy)).resolves.toBe("old-secret");
|
||||
});
|
||||
|
||||
it("passes through a value that was never encoded", async () => {
|
||||
await expect(decryptSystemSecret("plain-secret")).resolves.toBe(
|
||||
"plain-secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("upgrades a legacy value on the next write", async () => {
|
||||
const legacy = `encoded:${Buffer.from("old-secret").toString("base64")}`;
|
||||
const plaintext = await decryptSystemSecret(legacy);
|
||||
const sealed = await encryptSystemSecret(plaintext);
|
||||
|
||||
expect(isSystemEncrypted(sealed)).toBe(true);
|
||||
await expect(decryptSystemSecret(sealed)).resolves.toBe("old-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSO provider config", () => {
|
||||
it("seals only the secret fields", async () => {
|
||||
const sealed = await encryptSsoConfigSecrets({
|
||||
client_id: "termix",
|
||||
client_secret: "shhh",
|
||||
bindPassword: "ldap-pw",
|
||||
issuer_url: "https://idp.example",
|
||||
});
|
||||
|
||||
expect(sealed.client_id).toBe("termix");
|
||||
expect(sealed.issuer_url).toBe("https://idp.example");
|
||||
expect(isSystemEncrypted(sealed.client_secret as string)).toBe(true);
|
||||
expect(isSystemEncrypted(sealed.bindPassword as string)).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips a whole config", async () => {
|
||||
const original = {
|
||||
client_id: "termix",
|
||||
client_secret: "shhh",
|
||||
bindPassword: "ldap-pw",
|
||||
};
|
||||
|
||||
const restored = await decryptSsoConfigSecrets(
|
||||
await encryptSsoConfigSecrets(original),
|
||||
);
|
||||
|
||||
expect(restored).toEqual(original);
|
||||
});
|
||||
|
||||
it("covers both secret fields", () => {
|
||||
expect([...SSO_SECRET_FIELDS]).toEqual(["client_secret", "bindPassword"]);
|
||||
});
|
||||
|
||||
it("leaves a config without secrets untouched", async () => {
|
||||
const config = { client_id: "termix", scopes: "openid" };
|
||||
|
||||
await expect(encryptSsoConfigSecrets(config)).resolves.toEqual(config);
|
||||
await expect(decryptSsoConfigSecrets(config)).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it("does not let one unreadable secret take down the provider", async () => {
|
||||
const restored = await decryptSsoConfigSecrets({
|
||||
client_id: "termix",
|
||||
client_secret: "sysenc:v1:bad",
|
||||
});
|
||||
|
||||
// The rest of the config survives; login fails later with a clearer error.
|
||||
expect(restored.client_id).toBe("termix");
|
||||
expect(restored.client_secret).toBe("sysenc:v1:bad");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user