Files
Termix/src/backend/tests/database/repositories/host-credential-repositories.test.ts
T
+2
Luke GustafsonGitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>L.H.default-studentBrad Bakercopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Copilot Autofix powered by AIBrennan NeohbrennanneohXtraLargeZacharyZcR
1a26628a48 release-2.6.0 (#1085)
* fix: general bug fixes

* fix: general qol additions

* ci(deps): bump actions/setup-node in the github-actions group (#1068)

Bumps the github-actions group with 1 update: [actions/setup-node](https://github.com/actions/setup-node).


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)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  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 28 updates (#1069)

Bumps the dev-patch-updates group with 28 updates:

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.2` | `2.5.4` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.5` | `6.43.6` |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.15` | `1.2.17` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.18` | `1.1.20` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.6` | `1.3.8` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.18` | `1.1.20` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.19` | `2.1.21` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.11` | `2.1.12` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.18` | `1.1.20` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.11` | `1.1.13` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.13` | `1.2.15` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.2` | `2.3.4` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.11` | `1.1.12` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.2` | `1.4.4` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.2` | `1.3.4` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.16` | `1.1.18` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.11` | `1.2.13` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.2` | `4.3.3` |
| [@uiw/codemirror-extensions-langs](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` |
| [@uiw/codemirror-theme-github](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` |
| [@uiw/react-codemirror](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.9` | `4.1.10` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.9` | `4.1.10` |
| [i18next](https://github.com/i18next/i18next) | `26.3.4` | `26.3.6` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.1` | `1.6.3` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.8` | `17.0.10` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.2` | `4.3.3` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.9` | `4.1.10` |


Updates `@biomejs/biome` from 2.5.2 to 2.5.4
- [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.4/packages/@biomejs/biome)

Updates `@codemirror/view` from 6.43.5 to 6.43.6
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@radix-ui/react-accordion` from 1.2.15 to 1.2.17
- [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.18 to 1.1.20
- [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.6 to 1.3.8
- [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.18 to 1.1.20
- [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.19 to 2.1.21
- [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.11 to 2.1.12
- [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.18 to 1.1.20
- [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.11 to 1.1.13
- [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.13 to 1.2.15
- [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.2 to 2.3.4
- [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.11 to 1.1.12
- [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.2 to 1.4.4
- [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-switch` from 1.3.2 to 1.3.4
- [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.16 to 1.1.18
- [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.11 to 1.2.13
- [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 `@tailwindcss/vite` from 4.3.2 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/@tailwindcss-vite)

Updates `@uiw/codemirror-extensions-langs` from 4.25.10 to 4.25.11
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11)

Updates `@uiw/codemirror-theme-github` from 4.25.10 to 4.25.11
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11)

Updates `@uiw/react-codemirror` from 4.25.10 to 4.25.11
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11)

Updates `@vitest/coverage-v8` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/ui)

Updates `i18next` from 26.3.4 to 26.3.6
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.4...v26.3.6)

Updates `radix-ui` from 1.6.1 to 1.6.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui)

Updates `react-i18next` from 17.0.8 to 17.0.10
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.8...v17.0.10)

Updates `tailwindcss` from 4.3.2 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss)

Updates `vitest` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.6
  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.17
  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.20
  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.8
  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.20
  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.21
  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.12
  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.20
  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.13
  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.15
  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.4
  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.12
  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.4
  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.4
  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.18
  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.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@uiw/codemirror-extensions-langs"
  dependency-version: 4.25.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@uiw/codemirror-theme-github"
  dependency-version: 4.25.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@uiw/react-codemirror"
  dependency-version: 4.25.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: radix-ui
  dependency-version: 1.6.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.10
  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 ws in the prod-patch-updates group (#1071)

Bumps the prod-patch-updates group with 1 update: [ws](https://github.com/websockets/ws).


Updates `ws` from 8.21.0 to 8.21.1
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.0...8.21.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the major-updates group with 2 updates (#1072)

Bumps the major-updates group with 2 updates: [nanoid](https://github.com/ai/nanoid) and [typescript](https://github.com/microsoft/TypeScript).


Updates `nanoid` from 5.1.16 to 6.0.0
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.16...6.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: nanoid
  dependency-version: 6.0.0
  dependency-type: direct:production
  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>

* fix: general qol additions and new analytics/telemetrics feature

* fix: incorrect version sent to posthog

* feat: add multiplayer/shared sessions for terminal and guacd

* feat: rework Electron desktop app to run standalone-first with optional two-way sync to a remote Termix server

* Fix Guacamole tab visibility lifecycle (#1074)

Co-authored-by: default-student <default-student@github.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix(alerts): send channel config as object payload instead of JSON string (#1075)

* fix tmux-monitor tailscale issue (#1076)

* Initial plan

* fix(tmux-monitor): explicitly handle tailscale auth in PanePreview hostConfig

For Tailscale-auth hosts the pane-preview attach path was building the
Terminal hostConfig with only the generic spread of host fields.  This
could omit or mismap auth-critical details and trigger a plain TCP/SSH
reachability path that doesn't work with Tailscale-only SSH endpoints.

The fix branches on `host.authType === "tailscale"` and:
- Carries `authType: "tailscale"` explicitly so the backend always selects
  the Tailscale-aware PTY path regardless of how the host object evolves.
- Derives `port` from `host.sshPort ?? host.port` so Tailscale SSH
  endpoints on a non-default SSH port are reached correctly.
- Leaves all non-tailscale auth types on the unchanged code path.

Reattach (bumping instanceId + attachNonce) continues to work because
terminalHostConfig is recomputed on every render with the latest
instanceIdRef.current value.

* refactor(tmux-monitor): simplify tailscale port logic with extracted variable

Address code review feedback: extract resolvedPort into a local variable
to avoid the duplicated `host.sshPort ?? host.port` expression that was
assigned to both `port` and `sshPort`.  Restructure as an if/else block
instead of an IIFE for readability.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* chore: run prettier

* chore: update beta release text

* fix: cant update credential of an RDP host

* feat: add custom key shortcuts

* feat: add support for MFA over SSH

* fix: Invalid websocket frame causing code 10006 crash triggering restart loop

* fix(net): correct SSRF blocklist false-positive blocking all outbound IPv4 (#1079)

* fix: correct IPv4-mapped-IPv6 blocklist entry blocking all outbound IPv4

::ffff:0:0/96 in the IPv6 blocklist matches every IPv4 address once
mapped, since Node's BlockList compares addresses in their mapped form
internally regardless of the declared family. This caused
safeOutboundFetch to reject all IPv4-resolved destinations as private,
breaking outbound requests (e.g. ntfy/webhook notifications) whenever
DNS resolved to IPv4. Replaced with individual mapped ranges mirroring
the existing IPv4 blocklist.

* test: cover isBlockedAddress and link the Node BlockList citation

Exports isBlockedAddress so its family-crossing behavior around
IPv4-mapped-IPv6 addresses can actually be asserted, instead of relying
on manual container debugging to notice a regression. Also swaps the
prior "Node's BlockList compares addresses in mapped form" comment for
one citing the documented example in the Node docs
(https://nodejs.org/api/net.html#class-netblocklist), since that
behavior isn't otherwise obvious from the addSubnet/check call sites.

Related: Termix-SSH/Support#1024

* refactor: derive IPv6 mirror from IPv4 list, split DNS error messages

Two follow-ups from review:

- The IPv6 blocklist previously hand-duplicated each IPv4 range as its
  IPv4-mapped-IPv6 equivalent. Nothing enforced the two stayed in sync,
  which is exactly how the original bug (a mismatched ::ffff:0:0/96
  entry blocking all IPv4) was introduced in the first place. Now
  derived from a single blockedIpv4Ranges list in one loop.

- The connect.lookup hook threw the same "Private destinations are not
  allowed" for both an empty DNS result and an actually-blocked address.
  An empty result is a resolution failure, not a privacy decision, and
  conflating the two is the same kind of opaque-error problem that made
  this bug slow to diagnose in production. Split into distinct messages.

Also extracted the lookup hook itself (createDnsLookupHook) so it can be
unit-tested against a fake resolver directly, instead of only through a
real fetch()/Agent call — the bug lived entirely in this callback, and
undici wraps any error thrown here as a generic "fetch failed" TypeError,
which is why isolating it matters for testability.

---------

Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com>

* fix(ssh): do not offer chacha20-poly1305 without the native ssh2 binding (#1081)

The availability probe treated a working OpenSSL "chacha20" cipher as proof
that chacha20-poly1305@openssh.com is usable. It is not: ssh2 pure-JS
chacha20-poly1305 corrupts the transport, so the peer aborts the KEX
("incomplete message [preauth]") and the connection times out. Easy to hit
on jump-host connections whose target sshd negotiates chacha20-poly1305
first.

Only trust the native binding (sshcrypto.node); otherwise leave the cipher
disabled so filterCiphers() drops it and AES-GCM is negotiated instead.

Co-authored-by: XtraLarge <>

* fix: add Swiss German server layout (#1078)

* chore: update release notes

* feat: continue improving desktop app 2-way sync with logic fixes and a migration dialog

* fix: dekstop app showing auth form without syncing

* feat: create desktop auto sessions for existing setups

* feat: add electron backend killing

* fix: electron login and session related bugs and updated readme for v2.6.0

* chore: finalize release notes

* fix: click to expand hosts including extra bottom margin

* fix: desktop auth modal failing to log users in

* fix: desktop app failing to sync

* fix: reverse proxy causing sync error

* chore: lint, format, and bump version to 2.6.0

* chore: sync Crowdin translations for 2.6.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: L.H. <117188168+default-student@users.noreply.github.com>
Co-authored-by: default-student <default-student@github.com>
Co-authored-by: Brad Baker <xyzulu@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Brennan Neoh <brennanneoh@users.noreply.github.com>
Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: XtraLarge <eMail@WilliWerres.de>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
2026-07-26 18:47:27 -05:00

741 lines
23 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { CredentialRepository } from "../../../database/repositories/credential-repository.js";
import { HostRepository } from "../../../database/repositories/host-repository.js";
import { DataCrypto } from "../../../utils/data-crypto.js";
describe("HostRepository and CredentialRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
vi.restoreAllMocks();
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepositories(
onCredentialWrite?: () => void,
onHostWrite?: () => void,
): 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
);
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'user', 'hash'),
('user-2', 'other', 'hash');
`);
return {
credentials: new CredentialRepository(context, onCredentialWrite),
hosts: new HostRepository(context, onHostWrite),
sqlite: context.sqlite!,
};
}
it("creates, finds, updates, lists, and deletes credentials", async () => {
const repo = await createRepositories();
const created = await repo.credentials.create({
userId: "user-1",
name: "primary",
authType: "password",
username: "root",
password: "secret",
folder: "prod",
});
expect(created.id).toBeGreaterThan(0);
expect(await repo.credentials.listFolders("user-1")).toEqual(["prod"]);
expect(
(await repo.credentials.findByIdForUser("user-1", created.id))?.name,
).toBe("primary");
expect((await repo.credentials.findById(created.id))?.name).toBe("primary");
// Backdate updated_at so the update's CURRENT_TIMESTAMP bump is
// 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);
const updated = await repo.credentials.updateForUser("user-1", created.id, {
folder: "ops",
tags: "linux,admin",
});
expect(updated?.folder).toBe("ops");
expect(updated?.updatedAt).not.toBe("2000-01-01 00:00:00");
expect(
await repo.credentials.findByIdForUser("user-2", created.id),
).toBeNull();
expect(await repo.credentials.deleteForUser("user-1", created.id)).toEqual({
syncId: expect.any(String),
});
expect(
await repo.credentials.findByIdForUser("user-1", created.id),
).toBeNull();
});
it("deletes user credentials through the cleanup boundary", async () => {
const onWrite = vi.fn();
const repo = await createRepositories(onWrite);
await repo.credentials.create({
userId: "user-1",
name: "primary",
authType: "password",
});
await repo.credentials.create({
userId: "user-1",
name: "secondary",
authType: "key",
});
await repo.credentials.create({
userId: "user-2",
name: "other",
authType: "password",
});
onWrite.mockClear();
await expect(repo.credentials.deleteByUserId("user-1")).resolves.toBe(2);
expect(await repo.credentials.listByUserId("user-1")).toEqual([]);
expect((await repo.credentials.listByUserId("user-2")).length).toBe(1);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("loads credentials through the decryption boundary", async () => {
const repo = await createRepositories();
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(DataCrypto, "decryptRecords").mockImplementation(
(_tableName, records) => records,
);
vi.spyOn(DataCrypto, "decryptRecord").mockImplementation(
(_tableName, record) => record,
);
const created = await repo.credentials.create({
userId: "user-1",
name: "primary",
authType: "password",
username: "root",
password: "secret",
folder: "prod",
});
await expect(
repo.credentials.listDecryptedByUserId("user-1"),
).resolves.toMatchObject([{ id: created.id, password: "secret" }]);
await expect(
repo.credentials.findDecryptedByIdForUser("user-1", created.id),
).resolves.toMatchObject({ id: created.id, password: "secret" });
expect(DataCrypto.decryptRecords).toHaveBeenCalledWith(
"ssh_credentials",
expect.arrayContaining([expect.objectContaining({ id: created.id })]),
"user-1",
Buffer.from("user-key"),
);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_credentials",
expect.objectContaining({ id: created.id }),
"user-1",
Buffer.from("user-key"),
);
});
it("encrypts credential writes with the user key", async () => {
const repo = await createRepositories();
vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(DataCrypto, "encryptRecord").mockImplementation(
(_tableName, record) =>
({
...record,
password: "user-encrypted-password",
}) as typeof record,
);
vi.spyOn(DataCrypto, "decryptRecord").mockImplementation(
(_tableName, record) => record,
);
const created = await repo.credentials.createEncryptedForUser("user-1", {
userId: "user-1",
name: "primary",
authType: "password",
username: "root",
password: "secret",
});
const raw = repo.sqlite
.prepare("SELECT password FROM ssh_credentials WHERE id = ?")
.get(created.id) 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 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 };
expect(updatedRaw.password).toBe("user-encrypted-password");
expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"ssh_credentials",
expect.objectContaining({ password: "updated-secret" }),
"user-1",
Buffer.from("user-key"),
);
});
it("checks credential import identity", async () => {
const repo = await createRepositories();
await repo.credentials.create({
userId: "user-1",
name: "primary",
authType: "password",
username: "root",
});
await expect(
repo.credentials.existsForImportIdentity("user-1", "primary", "root"),
).resolves.toBe(true);
await expect(
repo.credentials.existsForImportIdentity("user-1", "primary", "admin"),
).resolves.toBe(false);
});
it("renames credential folders through the write boundary", async () => {
const onWrite = vi.fn();
const repo = await createRepositories(onWrite);
const primary = await repo.credentials.create({
userId: "user-1",
name: "primary",
authType: "password",
folder: "prod",
});
await repo.credentials.create({
userId: "user-1",
name: "secondary",
authType: "key",
folder: "prod",
});
await repo.credentials.create({
userId: "user-2",
name: "other",
authType: "password",
folder: "prod",
});
repo.sqlite
.prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
.run("2000-01-01 00:00:00", primary.id);
onWrite.mockClear();
await expect(
repo.credentials.renameFolder("user-1", "prod", "ops"),
).resolves.toBe(2);
expect(await repo.credentials.listFolders("user-1")).toEqual(["ops"]);
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 };
expect(renamedRow.updated_at).not.toBe("2000-01-01 00:00:00");
});
it("returns empty credential reads when user data is locked", async () => {
const repo = await createRepositories();
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(null);
const created = await repo.credentials.create({
userId: "user-1",
name: "primary",
authType: "password",
username: "root",
password: "secret",
});
await expect(
repo.credentials.listDecryptedByUserId("user-1"),
).resolves.toEqual([]);
await expect(
repo.credentials.findDecryptedByIdForUser("user-1", created.id),
).resolves.toBeNull();
});
it("creates, finds, updates, lists, and deletes hosts", async () => {
const repo = await createRepositories();
const host = await repo.hosts.create({
userId: "user-1",
name: "web-1",
ip: "10.0.0.10",
port: 22,
username: "root",
authType: "password",
});
expect(host.id).toBeGreaterThan(0);
expect((await repo.hosts.findById(host.id))?.name).toBe("web-1");
expect(
(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);
const updated = await repo.hosts.updateForUser("user-1", host.id, {
name: "web-1-renamed",
folder: "prod",
});
expect(updated?.name).toBe("web-1-renamed");
expect(updated?.updatedAt).not.toBe("2000-01-01 00:00:00");
expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull();
expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
syncId: expect.any(String),
});
expect(await repo.hosts.findById(host.id)).toBeNull();
});
it("encrypts host writes through the repository boundary", async () => {
const repo = await createRepositories();
vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(DataCrypto, "encryptRecord").mockImplementation(
(_tableName, record) =>
({
...record,
password: "encrypted-host-password",
}) as typeof record,
);
vi.spyOn(DataCrypto, "decryptRecord").mockImplementation(
(_tableName, record) => record,
);
const created = await repo.hosts.createEncryptedForUser("user-1", {
userId: "user-1",
name: "web-1",
ip: "10.0.0.10",
port: 22,
username: "root",
authType: "password",
password: "secret",
});
const raw = repo.sqlite
.prepare("SELECT password FROM ssh_data WHERE id = ?")
.get(created.id) 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 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 };
expect(updatedRaw.password).toBe("encrypted-host-password");
expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ password: "updated-secret" }),
"user-1",
Buffer.from("user-key"),
);
});
it("loads hosts through the decryption boundary", async () => {
const repo = await createRepositories();
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
Buffer.from("user-key"),
);
vi.spyOn(DataCrypto, "decryptRecords").mockImplementation(
(_tableName, records) => records,
);
const host = await repo.hosts.create({
userId: "user-1",
name: "web-1",
ip: "10.0.0.10",
port: 22,
username: "root",
authType: "password",
password: "secret",
});
await expect(
repo.hosts.listDecryptedByUserId("user-1"),
).resolves.toMatchObject([{ id: host.id, password: "secret" }]);
expect(DataCrypto.decryptRecords).toHaveBeenCalledWith(
"ssh_data",
expect.arrayContaining([expect.objectContaining({ id: host.id })]),
"user-1",
Buffer.from("user-key"),
);
});
it("checks host import identity", async () => {
const repo = await createRepositories();
await repo.hosts.create({
userId: "user-1",
name: "web-1",
ip: "10.0.0.10",
port: 22,
username: "root",
authType: "password",
});
await expect(
repo.hosts.existsForImportIdentity("user-1", "10.0.0.10", 22, "root"),
).resolves.toBe(true);
await expect(
repo.hosts.existsForImportIdentity("user-1", "10.0.0.10", 2222, "root"),
).resolves.toBe(false);
});
it("deletes user hosts through the cleanup boundary", async () => {
const onWrite = vi.fn();
const repo = await createRepositories(undefined, onWrite);
await repo.hosts.create({
userId: "user-1",
name: "web-1",
ip: "10.0.0.10",
port: 22,
username: "root",
authType: "password",
});
await repo.hosts.create({
userId: "user-1",
name: "web-2",
ip: "10.0.0.11",
port: 22,
username: "root",
authType: "password",
});
await repo.hosts.create({
userId: "user-2",
name: "other",
ip: "10.0.0.12",
port: 22,
username: "root",
authType: "password",
});
onWrite.mockClear();
await expect(repo.hosts.deleteByUserId("user-1")).resolves.toBe(2);
expect(await repo.hosts.listByUserId("user-1")).toEqual([]);
expect((await repo.hosts.listByUserId("user-2")).length).toBe(1);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("lists bulk update state and updates multiple owned hosts", async () => {
const onWrite = vi.fn();
const repo = await createRepositories(undefined, onWrite);
const first = await repo.hosts.create({
userId: "user-1",
name: "web-1",
ip: "10.0.0.10",
port: 22,
username: "root",
authType: "password",
statsConfig: JSON.stringify({ cpu: true }),
});
const second = await repo.hosts.create({
userId: "user-1",
name: "web-2",
ip: "10.0.0.11",
port: 22,
username: "root",
authType: "password",
});
const other = await repo.hosts.create({
userId: "user-2",
name: "other",
ip: "10.0.0.12",
port: 22,
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);
onWrite.mockClear();
const states = await repo.hosts.listBulkUpdateState("user-1", [
first.id,
second.id,
other.id,
]);
expect(states.map((state) => state.id)).toEqual([first.id, second.id]);
await expect(
repo.hosts.updateManyForUser("user-1", [first.id, second.id, other.id], {
folder: "ops",
}),
).resolves.toBe(2);
expect((await repo.hosts.findById(first.id))?.folder).toBe("ops");
expect((await repo.hosts.findById(other.id))?.folder).toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
expect((await repo.hosts.findById(first.id))?.updatedAt).not.toBe(
"2000-01-01 00:00:00",
);
expect((await repo.hosts.findById(second.id))?.updatedAt).not.toBe(
"2000-01-01 00:00:00",
);
});
it("records credential usage and increments usage counters", async () => {
const repo = await createRepositories();
const credential = await repo.credentials.create({
userId: "user-1",
name: "primary",
authType: "password",
});
const host = await repo.hosts.create({
userId: "user-1",
name: "web-1",
ip: "10.0.0.10",
port: 22,
username: "root",
authType: "credential",
credentialId: credential.id,
});
await repo.credentials.recordUsage(
"user-1",
credential.id,
host.id,
"2026-06-26T00:00:00.000Z",
);
const updated = await repo.credentials.findByIdForUser(
"user-1",
credential.id,
);
expect(updated?.usageCount).toBe(1);
expect(updated?.lastUsed).toBe("2026-06-26T00:00:00.000Z");
});
it("cleans host access before deleting a host", async () => {
const repo = await createRepositories();
const host = await repo.hosts.create({
userId: "user-1",
name: "shared-host",
ip: "10.0.0.20",
port: 22,
username: "root",
authType: "password",
});
repo.sqlite
.prepare(
"INSERT INTO host_access (host_id, user_id, granted_by) VALUES (?, ?, ?)",
)
.run(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({
syncId: expect.any(String),
});
});
});