release-2.5.1 (#1067)

* chore(deps): bump node from 24-slim to 26-slim in /docker in the docker-major-updates group (#1021)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps): bump node in /docker in the docker-major-updates group

Bumps the docker-major-updates group in /docker with 1 update: node.


Updates `node` from 24-slim to 26-slim

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-slim
  dependency-type: direct:production
  dependency-group: docker-major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 24 updates (#1023)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps-dev): bump the dev-patch-updates group with 24 updates

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

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.1` | `2.5.2` |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.10.3` | `6.10.4` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.1` | `6.43.5` |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.14` | `1.2.15` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.17` | `1.1.18` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.5` | `1.3.6` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.17` | `1.1.18` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.18` | `2.1.19` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.10` | `2.1.11` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.17` | `1.1.18` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.10` | `1.1.11` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.12` | `1.2.13` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.1` | `2.3.2` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.10` | `1.1.11` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.1` | `1.4.2` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.1` | `1.3.2` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.15` | `1.1.16` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.10` | `1.2.11` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.1` | `4.3.2` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.2` | `6.0.3` |
| [i18next](https://github.com/i18next/i18next) | `26.3.1` | `26.3.4` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.0` | `1.6.1` |
| [sharp](https://github.com/lovell/sharp) | `0.35.2` | `0.35.3` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.1` | `4.3.2` |


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

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

Updates `@codemirror/view` from 6.43.1 to 6.43.5
- [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.14 to 1.2.15
- [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.17 to 1.1.18
- [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.5 to 1.3.6
- [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.17 to 1.1.18
- [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.18 to 2.1.19
- [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.10 to 2.1.11
- [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.17 to 1.1.18
- [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.10 to 1.1.11
- [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.12 to 1.2.13
- [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.1 to 2.3.2
- [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.10 to 1.1.11
- [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.1 to 1.4.2
- [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.1 to 1.3.2
- [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.15 to 1.1.16
- [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.10 to 1.2.11
- [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.1 to 4.3.2
- [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.2/packages/@tailwindcss-vite)

Updates `@vitejs/plugin-react` from 6.0.2 to 6.0.3
- [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.3/packages/plugin-react)

Updates `i18next` from 26.3.1 to 26.3.4
- [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.1...v26.3.4)

Updates `radix-ui` from 1.6.0 to 1.6.1
- [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 `sharp` from 0.35.2 to 0.35.3
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.2...v0.35.3)

Updates `tailwindcss` from 4.3.1 to 4.3.2
- [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.2/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/commands"
  dependency-version: 6.10.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.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.15
  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.18
  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.6
  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.18
  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.19
  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.11
  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.18
  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.11
  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.13
  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.2
  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.11
  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.2
  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.2
  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.16
  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.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: radix-ui
  dependency-version: 1.6.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: 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-patch-updates group with 2 updates (#1025)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps): bump the prod-patch-updates group with 2 updates

Bumps the prod-patch-updates group with 2 updates: [axios](https://github.com/axios/axios) and [nanoid](https://github.com/ai/nanoid).


Updates `axios` from 1.18.0 to 1.18.1
- [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.0...v1.18.1)

Updates `nanoid` from 5.1.15 to 5.1.16
- [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.15...5.1.16)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: nanoid
  dependency-version: 5.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: 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 (#1026)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps): bump the prod-minor-updates group with 3 updates

Bumps the prod-minor-updates group with 3 updates: [js-yaml](https://github.com/nodeca/js-yaml), [motion](https://github.com/motiondivision/motion) and [undici](https://github.com/nodejs/undici).


Updates `js-yaml` from 5.0.0 to 5.2.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/5.0.0...5.2.1)

Updates `motion` from 12.40.0 to 12.42.2
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.40.0...v12.42.2)

Updates `undici` from 8.5.0 to 8.7.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.5.0...v8.7.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: motion
  dependency-version: 12.42.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: undici
  dependency-version: 8.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump electron from 42.4.1 to 43.0.0 in the major-updates group (#1027)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps-dev): bump electron in the major-updates group

Bumps the major-updates group with 1 update: [electron](https://github.com/electron/electron).


Updates `electron` from 42.4.1 to 43.0.0
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v42.4.1...v43.0.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 43.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Fix MC syntax highlighting artifacts (#996)

* Filter dashboard status hosts (#997)

* Persist dashboard service link changes (#999)

* Fix snippet text overflow (#1000)

* Persist remote desktop credential auth (#1001)

* Guard language switching failures (#1002)

* Resolve tunnel source credentials (#1003)

* Support Vault auth for monitors (#1004)

* Fix Windows file delete command (#1005)

* Fix release artifact checkout ref (#1006)

* Fix command palette escape in fullscreen (#1008)

* Fix alerts and audit log data normalization (#1010)

* Fix macOS VNC protocol negotiation (#1012)

* Fix port knocking before SSH connect (#1013)

* Allow Escape to close link confirmation (#1014)

* Prevent Electron modifier wheel zoom (#1016)

* Fix credential auth optional password (#1009)

* Retry transient terminal DNS lookups (#1011)

* Retry transient terminal DNS lookups

* Apply DNS retry to SSH entry points

* Fix OIDC redirect forwarded port handling (#1007)

* Preserve recent open tabs on startup (#1015)

* Fix fish prompt OSC highlighting (#998)

* Fix terminal font selection (#1018)

* fix: font legibility (#1019)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* fix: font legibility

Text was entirely unreadable in places for me. Especially with themes
like Catppuccin. The muted-foreground text and the tags too similiar to
the background.

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: russell <git@0896c69e.com>

* fix(file-manager): chunked uploads fail with 'Expected multipart/form-data request' (#1020)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* fix(file-manager): use postForm for chunked uploads so multipart content-type is sent

The fileManagerApi axios instance defaults to Content-Type:
application/json. Axios 1.x's default transformRequest converts a
FormData body to JSON whenever the request content type is
application/json, so every chunk POSTed to /ssh/uploadFileChunk
arrived as a JSON body like {"chunk":{}} and the backend rejected
it with 400 'Expected multipart/form-data request'. This breaks all
uploads of files larger than the 1.5 GiB chunking threshold.

The non-chunked path already uses postForm for /ssh/uploadFileStream;
use it for the chunk path too so axios keeps the FormData intact and
the browser sets the multipart boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat: implement OIDC back-channel logout support with session management (#1028)

* feat: implement OIDC back-channel logout support with session management

* Fix OIDC back-channel logout handling

* Require logout token replay identifiers

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* Add API key host enrollment endpoint (#1029)

* Fix tmux detection for non-POSIX shells (#1030)

* Fix OPKSSH js-yaml ESM import (#1031)

* Fix Android Vietnamese IME input (#1032)

* Fix Firefox RDP clipboard paste (#1033)

* Fix Proxmox discovery over HTTPS (#1041)

* Fix external editor actions in file preview (#1042)

* Allow pinned hosts with name sorting (#1043)

* Fix Firefox desktop OIDC callback (#1044)

* feat(session): add recording and replay (#1049)

* Fix status checks through jump hosts (#1045)

* Add terminal font size shortcuts (#1047)

* feat: add Open File Manager to tab right-click menu (#1051)

Co-authored-by: SankeerthNara <sankeerthnara@gmail.com>

* perf: frontend request cache, poll pause, and code-split shell (#1052)

Host/status caching, shell code-split, SSH pool waits, host-metrics concurrency, background-tab idle, per-host status subscriptions, homepage poll quieting, and virtualized host sidebar + file manager lists.

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* feat: save quick connect sessions as hosts (#1055)

* fix: restore sudo password autofill settings (#1056)

* fix: preserve file editor position on save (#1057)

* fix: sync cloud preference storage mode (#1058)

* fix: render RDP sessions at native pixel density (#1059)

* fix: restore database import in embedded desktop mode (#1060)

* Update Auto-complete.tsx (#1061)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore: donation bar reporting wrong result

* feat: add Open File Manager to tab right-click menu (#1046)

* Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050)

This reverts commit 0712fdd731.

* Remove donation badge from README

Removed donation badge from README.

* Delete .github/workflows/donation-goal.yml

* Update Auto-complete.tsx

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* feat(auth): opt-in OIDC DEK unlock for API-key requests (ALLOW_APIKEY_DATA_UNLOCK) (#1064)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore: donation bar reporting wrong result

* feat: add Open File Manager to tab right-click menu (#1046)

* Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050)

This reverts commit 0712fdd731.

* Remove donation badge from README

Removed donation badge from README.

* Delete .github/workflows/donation-goal.yml

* feat(auth): opt-in OIDC DEK unlock for API-key requests

API keys authenticate but cannot touch the encrypted credential/host store
('User data not unlocked') unless the user has a live interactive session,
making them unusable for headless automation. For OIDC users the DEK is
server-derivable (deriveOIDCSystemKey), so handleApiKeyAuth can unlock it
without a password.

Gated behind ALLOW_APIKEY_DATA_UNLOCK (default off) because enabling it widens
the blast radius of a leaked API key. OIDC-only; password users are untouched.

Refs #1063

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Sankeerth Nara <sankeerthnara@gmail.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* chore: package lock sync

* Add Proxmox guest auto sync (#1053)

* draft: database layer refactor (#1054)

* feat(sshid) - sshid.io equivalent for termix (#919)

* feat(ssh-id): database schema, migrations and field encryption

Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys
stored plaintext for the unauthenticated resolver; CA private key registered
for per-user field encryption), with UNIQUE(user_id), an index on
ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations.

* feat(ssh-id): backend API — resolver, key management, CA and certificates

Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver
(+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store +
noindex headers on every resolver response including early 404s. Authenticated
management: claim/rename/delete handle, add/import/generate/enable/delete keys,
and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate
issuance. Audit logging on all mutations; UNIQUE races map to a precise 409.
Unit tests for key parsing and certificate signing (ssh-keygen-validated).

* feat(ssh-id): frontend panel, API client and i18n

SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL +
curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with
server trust command, and per-key certificate issuance. API client re-exported
through main-axios.ts; all strings i18n'd.

* style(ssh-id): align panel and resolver page with Termix theme

- Rebuild the SSH ID sidebar panel with the theme's square components
  (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards;
  use accent-brand and destructive tokens rather than raw red/green.
- Fix panel scrolling: move overflow to a block scroll container so the
  cards keep their natural height instead of being clipped.
- Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix
  dark theme: square corners, #18181b/#303032 palette, #f59145 accent,
  uppercase section labels.
- Tidy copy: 'Save To Credentials' label, drop the redundant generate intro,
  and correct the generate tooltip (the key is stored when saving to vault).

* feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922)

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


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  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 11 updates (#923)

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

| Package | From | To |
| --- | --- | --- |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` |
| [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |


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

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

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [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.1/packages/@tailwindcss-vite)

Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9
- [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.9/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.8 to 4.1.9
- [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.9/packages/ui)

Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3)

Updates `lint-staged` from 17.0.7 to 17.0.8
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8)

Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

Updates `sharp` from 0.35.1 to 0.35.2
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2)

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [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.1/packages/tailwindcss)

Updates `vitest` from 4.1.8 to 4.1.9
- [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.9/packages/vitest)

---
updated-dependencies:
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: lint-staged
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.9
  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 nanoid in the prod-patch-updates group (#925)

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


Updates `nanoid` from 5.1.11 to 5.1.15
- [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.11...5.1.15)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 5.1.15
  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 5 updates (#926)

Bumps the major-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` |
| [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` |


Updates `js-yaml` from 4.2.0 to 5.0.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@types/node` from 25.9.2 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `concurrently` from 9.2.1 to 10.0.3
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3)

Updates `eslint` from 9.39.4 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: concurrently
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: eslint
  dependency-version: 10.5.0
  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>

* feat(ssh): add HashiCorp Vault SSH signer authentication

* fix: small fixes to vault feature to align with Termix codebase

* chore: add view docs links for vault/termix id

* fix: file upload fails with 400 and missing schema migrations on upgrade (#929)

Two bugs introduced in v2.4.1:

1. uploadFileStream uses fileManagerApi.post() which triggers axios's
   transformRequest to JSON-serialize the FormData because the instance
   default Content-Type is application/json. Change to postForm() which
   sets Content-Type: multipart/form-data so the browser XHR sends the
   correct multipart body with boundary.

2. Two schema items added to schema.ts were not included in migrateSchema()
   in db/index.ts, causing 500 errors on existing installations upgrading
   from v2.4.0:
   - user_preferences.status_color_scheme (no such column)
   - dashboard_service_links table (no such table)

Fixes #928

Co-authored-by: sash <sash@fominykh.io>

* fix: support PuTTY PPK ssh keys (#930)

* fix: chunk large file manager uploads (#932)

* fix: route dashboard hosts by protocol (#934)

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

* Sync role credential shares for OIDC users (#938)

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

* Preserve file path case in file manager UI (#941)

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

* feat: open all sessions in a folder and terminal custom theme color support

* feat: cross host file manager clipboard and several small bug fixes

* feat: tailscale/wireguard support and added a new status state for when backend is checking status

* feat: grafana like server stats history, new alert system, ntfy/webhook support

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

* fix: alert ui incorrectly using termix css and fixed issue with alert system not loading

* chore: start database layer refactor

* docs: plan database layer refactor

* docs: audit database layer refactor phase zero

* chore: add database runtime adapter skeleton

* chore: add settings repository skeleton

* chore: add user session repository skeleton

* chore: add host credential repository skeleton

* chore: add field encryption boundary

* chore: migrate settings route slice

* chore: migrate user settings routes

* chore: migrate host metrics settings routes

* chore: migrate acme settings route

* chore: migrate terminal settings route

* chore: migrate tailscale settings read

* chore: migrate guacamole settings reads

* chore: migrate session timeout settings reads

* chore: migrate auth route settings reads

* chore: migrate host metrics settings reads

* chore: migrate startup settings reads

* chore: migrate user settings cleanup

* chore: migrate password reset settings

* chore: migrate oidc legacy settings read

* chore: migrate user route settings slice

* chore: migrate oidc state settings

* chore: migrate user login settings reads

* chore: migrate user crypto settings

* chore: consolidate startup settings defaults

* chore: consolidate database settings import export

* chore: migrate core session auth paths

* chore: migrate remaining session auth paths

* chore: migrate admin user routes

* chore: migrate user route admin checks

* chore: migrate user lifecycle routes

* chore: migrate auth user lookups

* chore: migrate oidc user routes

* chore: migrate api key repository paths

* docs: add database gray rollout guide

* chore: migrate trusted device paths

* chore: migrate user session route user lookups

* chore: add database repository rollout guard

* chore: expose repository rollout status

* chore: warn on repository rollout misconfiguration

* chore: migrate remaining user lookup helpers

* chore: migrate ssh user lookups

* chore: migrate user settings admin lookups

* chore: migrate acme ssl user lookups

* chore: migrate audit log admin checks

* chore: migrate oidc account user updates

* chore: migrate password reset user updates

* chore: migrate user deletion core records

* chore: migrate snippet audit user lookups

* chore: migrate ldap user sync paths

* chore: migrate totp user updates

* chore: migrate rbac user checks

* chore: migrate rbac role paths

* chore: migrate permission role lookups

* chore: migrate rbac access list reads

* chore: migrate shared rbac reads

* chore: migrate rbac access writes

* chore: migrate permission host access

* chore: migrate role host access lookup

* chore: migrate snippet access lookup

* chore: migrate shared credential access lookups

* chore: migrate host access cleanup writes

* chore: migrate host list access checks

* chore: migrate host access cleanup routes

* chore: migrate shared credential role lookups

* chore: migrate user role cleanup

* chore: migrate admin role sync

* chore: migrate ldap role sync

* chore: migrate user role assignment

* chore: migrate sso provider access

* chore: migrate audit log access

* chore: migrate user preference access

* chore: migrate open tab access

* chore: migrate dismissed alert access

* chore: migrate homepage layout access

* chore: migrate network topology access

* chore: migrate dashboard service link access

* chore: migrate command history access

* chore: migrate recent activity cleanup

* chore: migrate ssh credential usage access

* chore: migrate transfer recent access

* chore: migrate file manager bookmark access

* chore: migrate c2s tunnel preset access

* chore: migrate homepage item access

* chore: migrate session recording access

* chore: migrate tmux session tag access

* chore: migrate opkssh token access

* chore: migrate vault token access

* chore: migrate vault profile access

* chore: migrate host metrics preference access

* chore: migrate host health access

* chore: migrate host metrics history access

* chore: migrate alert persistence access

* chore: route alert host lookup through repository

* chore: migrate user data export reads

* chore: route host metrics stats sync through repository

* chore: migrate host folder persistence

* chore: migrate host resolution reads

* chore: route jump host resolution reads

* chore: route docker console jump host reads

* chore: route docker ssh resolution reads

* chore: route proxmox discovery resolution reads

* chore: route file manager activity host reads

* chore: route host metrics resolution reads

* chore: route ssh auth credential reads

* chore: route tunnel endpoint credential reads

* chore: route credential deployment resolution reads

* chore: route command history host flag reads

* chore: route snippet execution resolution reads

* chore: route terminal host resolution reads

* chore: route vault oidc host resolution reads

* chore: route wake on lan host reads

* chore: route internal host list reads

* chore: route host key verification persistence

* chore: route credential read paths

* chore: route credential host usage reads

* chore: route credential folder rename

* chore: route host owner access checks

* chore: route shared credential source reads

* chore: route user host credential cleanup

* chore: route credential delete reads

* chore: route credential update reads

* chore: route host credential reads

* chore: route host read paths

* chore: route host projection reads

* chore: route host list reads

* chore: route snippet read paths

* chore: route snippet folder writes

* chore: route snippet crud paths

* chore: route snippet bulk import

* chore: route rbac ownership reads

* chore: route user count reads

* chore: route cleanup snippets folders

* chore: route shared credential persistence

* chore: route dashboard activity

* chore: route guacamole host reads

* chore: route host bulk lookups

* chore: remove unlock-only simple db ops

* chore: route host autostart persistence

* chore: route ldap provisioning through users

* chore: route credential encrypted writes

* chore: route host encrypted writes

* chore: route bulk host encrypted writes

* chore: route termix id credentials

* chore: route termix id ca persistence

* chore: route termix identity persistence

* chore: route credential system migration

* chore: isolate user encryption migration storage

* chore: remove legacy simple db ops

* chore: isolate legacy sqlite migration copy

* chore: route database settings import export

* chore: route database host credential export

* chore: route database host credential import

* chore: route database file-manager import export

* chore: route database alert usage import export

* chore: route database user checks

* chore: isolate auth lazy migration storage

* chore: route explicit database saves

* chore: initialize database save boundary

* chore: route migration snapshot saves

* chore: isolate sqlite import constraints

* chore: route import sqlite boundary

* chore: route user encryption migration store

* chore: centralize current repository runtime

* chore: route more current repositories

* chore: route activity repository runtimes

* chore: route token repository runtimes

* chore: route health repository runtimes

* chore: route identity repository runtimes

* chore: route rbac repository runtime

* chore: centralize current sqlite runtime access

* chore: route user deletion key cleanup

* chore: route user deletion vault cleanup

* chore: route user deletion homepage cleanup

* chore: route user deletion health cleanup

* chore: route user deletion alert cleanup

* chore: route user deletion identity cleanup

* chore: add database layer preupgrade backup

* Fix database repository type errors

* fix: complete post-merge compile fixes for database refactor

Restore missing DatabaseSaveTrigger/getDb imports, session log format
fallback, OIDC provider resolution, guacamole recording insert, and
passwordFallbackOnly typing after merging current dev.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>

* refactor(db): collapse repository rollout scaffolding into single factory

Repositories are now the only data path. Replaces the 41 current-*-repository
wrapper files, the DATABASE_LAYER_REPOSITORY_ROLLOUT flag/alias map and the
unused database/runtime adapter with repositories/factory.ts, a plain
DatabaseContext type and an in-memory TestSqliteDatabase test harness.

* refactor(db): route remaining raw DB access through repositories

proxmox, session-log, oidc-utils, webauthn and guacamole recording now use
repositories (new WebauthnCredentialRepository; SsoProviderRepository
listEnabled; HostRepository findDecryptedByIdAs/listProxmoxEnabled).
Remaining raw access: db boot code, simple-db-ops and docker.ts, which are
removed/restructured in later phases.

* feat(crypto): add UserKeyManager with system-wrapped per-user DEKs

New utils/user-keys.ts: one random 32-byte DEK per user, wrapped
AES-256-GCM under an HKDF key derived from the system ENCRYPTION_KEY
(per-user info string + AAD binding, versioned v3 wrap format stored in
settings). Synchronous unwrap-on-demand with a 15-minute cache so the
existing DataCrypto facade keeps its sync call sites. Not wired up yet.

* feat(crypto): boot-time DEK migration to system-wrapped v3 format

utils/crypto-migration/dek-migration.ts carries the legacy unwrap paths
(PBKDF2 password KEK, OIDC/WebAuthn system keys, hardcoded-default
fallback) and migrates every server-unwrappable DEK to the v3 wrap at
startup. Password-wrapped DEKs migrate at next login or from a live
session via adoptRecoveredDEK. Legacy rows are kept for now; cleanup
flips on once the new path is authoritative.

* refactor(crypto): make system-wrapped DEKs the authoritative key path

DataCrypto and AuthManager now read keys through UserKeyManager: DEKs are
always unwrappable server-side, so the in-memory unlock session, DEK-in-JWT
wrapping, session-expiry data locks and ALLOW_APIKEY_DATA_UNLOCK are gone.
utils/user-crypto.ts is deleted; boot migration now cleans legacy wraps.
A one-release shim adopts DEKs from legacy dataKeyWrap tokens so active
password users migrate without re-login. Password login migrates legacy
password-wrapped DEKs via migratePasswordUserAtLogin.

* refactor(crypto): remove pending share queue and credential sharing key

With server-unwrappable DEKs both sides of a share are always available,
so the needsReEncryption queue, CREDENTIAL_SHARING_KEY and the system_*
shadow columns on ssh_credentials are gone. A one-time boot cleanup
re-creates legacy pending share copies where possible (dropping
unresolvable ones with a warning) and drops the legacy columns.

* feat(auth): non-destructive password resets and admin reset endpoint

Password resets no longer destroy user data: the DEK is system-wrapped, so
forgot-password and admin resets are just a hash update plus session revoke.
The wipe branch survives only for accounts that never logged in since the
encryption upgrade and now requires explicit confirmDataWipe (surfaced as a
409 DATA_WIPE_REQUIRED; the reset UI asks for confirmation). Adds
POST /users/admin/reset-password and removes the dead re-encryption paths.

* refactor(ssh): consolidate four jump-host chain copies into one module

terminal, host-metrics and docker now use ssh/jump-host-chain.ts (already
shared by file-manager, tmux-monitor and docker-console); docker's inline
copy also drops its raw SimpleDBOps host/credential lookups in favor of
repositories.

* refactor(ssh): single shared createConnectionLog helper

file-manager-log.ts becomes ssh/connection-log.ts; the copies in docker.ts
and host-metrics-helpers.ts are gone.

* refactor(ssh): split docker module into layered directory

ssh/docker/{index,routes,session-manager,container-routes,console}.ts:
server boot and wiring in index, HTTP handlers in routes, SSH session
registry and command execution in session-manager. Code motion only;
port 30007/30009 and endpoints unchanged. Swagger now scans ssh
subdirectories.

* refactor(ssh): split tunnel module into layered directory

ssh/tunnel/{index,routes,manager}.ts: server boot in index, HTTP handlers
in routes, tunnel state and engine (connect/retry/autostart) in manager.
Code motion only; port 30003 and endpoints unchanged.

* refactor(backend): reorganize top-level layout

- ssh/ renamed to hosts/ (it covers SSH, RDP, VNC, Telnet, Docker, metrics)
- serial/serial.ts and guacamole/ moved inside hosts/
- dashboard.ts and homepage.ts moved to services/
- swagger.ts moved to utils/ with adjusted scan globs

Import paths and the generate:openapi script updated; ports and endpoints
unchanged.

* refactor(tests): move backend tests into src/backend/tests mirror tree

Backend *.test.ts files (and the test-support harness) no longer sit next
to source files; they live under src/backend/tests/ mirroring the source
layout. Imports rewritten accordingly; CLAUDE.md convention updated.

* refactor(hosts): group host modules into per-feature directories

file-manager/, metrics/ (incl. widgets, managers, alert-engine),
terminal/, tmux/ and tunnel/ each own their files; docker/ gains
container-runtime. Genuinely shared helpers (jump-host chain, host
resolver, connection pool, opkssh, vault, serial) stay at hosts/ root.
Pure file moves with import path updates; mirrored test paths follow.

* refactor(backend): final cleanup pass

- re-register WebAuthn passkey routes (registration was dropped in the
  #1054 merge, breaking passkey login) and document all six endpoints
- delete utils/simple-db-ops.ts (last caller migrated to DataCrypto)
- starter: use the typed serverReady export, collapse the four-way
  version lookup to env then package.json candidates
- add OpenAPI JSDoc to c2s-tunnel-presets endpoints
- strip block-divider comment banners

* feat: remove legacy "data_unlocked" field

* feat: refactor rbac/sharing to support new permissions and auth types

* feat: refactor rbac/sharing to support new permissions and auth types

* feat: add "id" to user profile hide list

* chore: root cleanup

* feat: add more donation references and a 30-day donation reminder

* chore: update readme

* feat: automate beta tests

* feat: add links to milestones

* fix: hoist github/google SSO defaults to module scope (#1065)

* fix(ssh-tools): allow clipboard paste in key recording field (#1066)

The broadcast key-recording input was marked readOnly, which makes
browsers block paste entirely (no context-menu Paste, Ctrl+V does
nothing). handleKeyDown also called preventDefault() unconditionally,
swallowing the Ctrl+V shortcut before a paste event could even fire.

Let Ctrl/Cmd+V pass through in handleKeyDown, drop readOnly, and add
an onPaste handler that reads the clipboard text and broadcasts it to
the selected terminals like any other captured keystroke.

Signed-off-by: emreumar <emreumar@users.noreply.github.com>
Co-authored-by: emreumar <emreumar@users.noreply.github.com>

* chore: write release notes

* chore: update release notes

* chore: update readmes

* chore: add crypto only reminder in en.json

* fix: macOS and cask errors on release workflow

* chore: sync Crowdin translations for 2.5.1

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: emreumar <emreumar@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Russell Poovey <09.our_seekers@icloud.com>
Co-authored-by: russell <git@0896c69e.com>
Co-authored-by: Subedi Bibek <77529535+questbibek@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Alexander Elsner <101340634+Bensonheimer992@users.noreply.github.com>
Co-authored-by: SankeerthNara <sankeerthnara@gmail.com>
Co-authored-by: Stephan Groth <96803994+Kalvalax@users.noreply.github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>
Co-authored-by: lhojun <ldgs3324@gmail.com>
Co-authored-by: Yunus Emre Umar <77045015+emre155@users.noreply.github.com>
Co-authored-by: emreumar <emreumar@users.noreply.github.com>
This commit is contained in:
Luke Gustafson
2026-07-19 12:29:52 -05:00
committed by GitHub
co-authored by emreumar dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ZacharyZcR Russell Poovey russell Subedi Bibek Claude Fable 5 Alexander Elsner SankeerthNara Stephan Groth DivByZero devdanetra Aleksandr Fominykh sash lhojun Yunus Emre Umar
parent fba645e92e
commit ddbdd5c437
562 changed files with 52303 additions and 23645 deletions
@@ -0,0 +1,379 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { AlertRepository } from "../../../database/repositories/alert-repository.js";
describe("AlertRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
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');
`);
return new AlertRepository(context, onWrite);
}
it("manages notification channels", async () => {
let writes = 0;
const repo = await createRepository(() => {
writes += 1;
});
const created = await repo.createNotificationChannel({
userId: "user-1",
name: "Ops",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
expect(created).toMatchObject({
user_id: "user-1",
name: "Ops",
type: "webhook",
enabled: 1,
});
const updated = await repo.updateNotificationChannel(created.id, "user-1", {
name: "Ops disabled",
enabled: false,
});
expect(updated).toMatchObject({ name: "Ops disabled", enabled: 0 });
expect(await repo.listNotificationChannels("user-1")).toHaveLength(1);
expect(await repo.deleteNotificationChannel(created.id, "user-2")).toBe(
false,
);
expect(await repo.deleteNotificationChannel(created.id, "user-1")).toBe(
true,
);
expect(await repo.listNotificationChannels("user-1")).toHaveLength(0);
expect(writes).toBe(3);
});
it("manages alert rules and linked channels", async () => {
const repo = await createRepository();
const ownedChannel = await repo.createNotificationChannel({
userId: "user-1",
name: "Owned",
type: "ntfy",
config: '{"url":"https://ntfy.test","topic":"termix"}',
enabled: true,
});
const foreignChannel = await repo.createNotificationChannel({
userId: "user-2",
name: "Foreign",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
const created = await repo.createAlertRule({
userId: "user-1",
hostId: null,
name: "CPU high",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 80,
thresholdDurationSeconds: 30,
cooldownMinutes: 5,
channels: [ownedChannel.id, foreignChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
expect(created).toMatchObject({
user_id: "user-1",
host_id: null,
name: "CPU high",
trigger_type: "cpu_threshold",
threshold_value: 80,
channels: [ownedChannel.id],
});
const updated = await repo.updateAlertRule(created.id, "user-1", {
name: "CPU very high",
hostId: 1,
channels: [],
now: "2026-01-02T00:00:00.000Z",
});
expect(updated).toMatchObject({
name: "CPU very high",
host_id: 1,
channels: [],
updated_at: "2026-01-02T00:00:00.000Z",
});
const rules = await repo.listAlertRules("user-1");
expect(rules).toHaveLength(1);
expect(rules[0].channels).toEqual([]);
expect(await repo.deleteAlertRule(created.id, "user-2")).toBe(false);
expect(await repo.deleteAlertRule(created.id, "user-1")).toBe(true);
});
it("lists, acknowledges, and prunes firings", async () => {
const repo = await createRepository();
const rule = await repo.createAlertRule({
userId: "user-1",
hostId: 1,
name: "Host offline",
enabled: true,
triggerType: "host_offline",
thresholdValue: null,
thresholdDurationSeconds: null,
cooldownMinutes: 15,
channels: [],
now: "2026-01-01T00:00:00.000Z",
});
await repo.createFiring({
userId: "user-1",
ruleId: rule.id,
hostId: 1,
hostName: "alpha",
value: null,
message: "down",
severity: "critical",
});
const listed = await repo.listAlertFirings({
userId: "user-1",
limit: 10,
offset: 0,
});
expect(listed.total).toBe(1);
expect(listed.firings[0]).toMatchObject({
rule_id: rule.id,
host_name: "alpha",
acknowledged: 0,
rule_name: "Host offline",
});
await repo.acknowledgeFiring(listed.firings[0].id, "user-1");
const unacknowledged = await repo.listAlertFirings({
userId: "user-1",
acknowledged: false,
limit: 10,
offset: 0,
});
expect(unacknowledged.total).toBe(0);
await repo.acknowledgeAllFirings("user-1");
repo.pruneFiringsOlderThan("user-1", 0);
});
it("loads enabled rules and notification channels for the alert engine", async () => {
const repo = await createRepository();
const channel = await repo.createNotificationChannel({
userId: "user-1",
name: "Ops",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
const disabledChannel = await repo.createNotificationChannel({
userId: "user-1",
name: "Disabled",
type: "webhook",
config: '{"url":"https://disabled.test"}',
enabled: false,
});
const rule = await repo.createAlertRule({
userId: "user-1",
hostId: null,
name: "CPU high",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 90,
thresholdDurationSeconds: 0,
cooldownMinutes: 15,
channels: [channel.id, disabledChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
expect(await repo.listEnabledRulesForHost(1)).toMatchObject([
{
id: rule.id,
userId: "user-1",
triggerType: "cpu_threshold",
enabled: true,
},
]);
expect(await repo.findRuleById(rule.id)).toMatchObject({
id: rule.id,
cooldownMinutes: 15,
});
expect(await repo.listEnabledChannelsForRule(rule.id)).toEqual([
{
id: channel.id,
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
},
]);
});
it("loads host display names for alert payloads", async () => {
const repo = await createRepository();
expect(await repo.getHostDisplayName(1)).toBe("alpha");
expect(await repo.getHostDisplayName(999)).toBeNull();
});
it("deletes all alert data for a user", async () => {
let writes = 0;
const repo = await createRepository(() => {
writes += 1;
});
const userChannel = await repo.createNotificationChannel({
userId: "user-1",
name: "Ops",
type: "webhook",
config: '{"url":"https://example.test"}',
enabled: true,
});
const otherChannel = await repo.createNotificationChannel({
userId: "user-2",
name: "Other",
type: "webhook",
config: '{"url":"https://other.test"}',
enabled: true,
});
const userRule = await repo.createAlertRule({
userId: "user-1",
hostId: 1,
name: "CPU high",
enabled: true,
triggerType: "cpu_threshold",
thresholdValue: 80,
thresholdDurationSeconds: 30,
cooldownMinutes: 5,
channels: [userChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
const otherRule = await repo.createAlertRule({
userId: "user-2",
hostId: null,
name: "Memory high",
enabled: true,
triggerType: "memory_threshold",
thresholdValue: 90,
thresholdDurationSeconds: 60,
cooldownMinutes: 10,
channels: [otherChannel.id],
now: "2026-01-01T00:00:00.000Z",
});
await repo.createFiring({
userId: "user-1",
ruleId: userRule.id,
hostId: 1,
hostName: "alpha",
value: 95,
message: "high",
severity: "warning",
});
await repo.createFiring({
userId: "user-2",
ruleId: otherRule.id,
hostId: 1,
hostName: "alpha",
value: 91,
message: "other",
severity: "warning",
});
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
firingsDeleted: 1,
ruleLinksDeleted: 1,
rulesDeleted: 1,
channelsDeleted: 1,
});
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
firingsDeleted: 0,
ruleLinksDeleted: 0,
rulesDeleted: 0,
channelsDeleted: 0,
});
expect(await repo.listNotificationChannels("user-1")).toEqual([]);
expect(await repo.listAlertRules("user-1")).toEqual([]);
expect(
await repo.listAlertFirings({ userId: "user-1", limit: 10, offset: 0 }),
).toEqual({ firings: [], total: 0 });
expect(await repo.listNotificationChannels("user-2")).toHaveLength(1);
expect(await repo.listAlertRules("user-2")).toHaveLength(1);
expect(await repo.listEnabledChannelsForRule(otherRule.id)).toHaveLength(1);
expect(writes).toBe(7);
});
});
@@ -0,0 +1,145 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { ApiKeyRepository } from "../../../database/repositories/api-key-repository.js";
describe("ApiKeyRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite?: () => void): Promise<{
apiKeys: 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
);
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'admin', 'hash'),
('user-2', 'target', 'hash');
`);
return {
apiKeys: new ApiKeyRepository(context, onWrite),
};
}
it("creates, lists, finds, updates last used time, and deletes keys", async () => {
const repo = await createRepository();
await repo.apiKeys.create({
id: "key-1",
userId: "user-2",
name: "deploy",
tokenHash: "hash",
tokenPrefix: "tmx_12345678",
createdAt: "2026-06-26T00:00:00.000Z",
expiresAt: null,
lastUsedAt: null,
isActive: true,
});
expect((await repo.apiKeys.findById("key-1"))?.name).toBe("deploy");
expect(
(await repo.apiKeys.listActiveByTokenPrefix("tmx_12345678")).map(
(key) => key.id,
),
).toEqual(["key-1"]);
expect(await repo.apiKeys.listAllWithUsers()).toMatchObject([
{
id: "key-1",
userId: "user-2",
username: "target",
tokenPrefix: "tmx_12345678",
},
]);
await repo.apiKeys.updateLastUsedAt("key-1", "2026-06-26T01:00:00.000Z");
expect((await repo.apiKeys.findById("key-1"))?.lastUsedAt).toBe(
"2026-06-26T01:00:00.000Z",
);
expect((await repo.apiKeys.delete("key-1"))?.name).toBe("deploy");
expect(await repo.apiKeys.findById("key-1")).toBeNull();
});
it("runs the write hook after key writes", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.apiKeys.create({
id: "key-1",
userId: "user-1",
name: "ops",
tokenHash: "hash",
tokenPrefix: "tmx_87654321",
isActive: true,
});
await repo.apiKeys.updateLastUsedAt("key-1", "2026-06-26T01:00:00.000Z");
await repo.apiKeys.delete("key-1");
expect(writeCount).toBe(3);
});
it("deletes all API keys for a user", async () => {
const repo = await createRepository();
await repo.apiKeys.create({
id: "key-1",
userId: "user-2",
name: "deploy",
tokenHash: "hash-1",
tokenPrefix: "tmx_11111111",
isActive: true,
});
await repo.apiKeys.create({
id: "key-2",
userId: "user-2",
name: "ops",
tokenHash: "hash-2",
tokenPrefix: "tmx_22222222",
isActive: true,
});
await repo.apiKeys.create({
id: "key-3",
userId: "user-1",
name: "admin",
tokenHash: "hash-3",
tokenPrefix: "tmx_33333333",
isActive: true,
});
await expect(repo.apiKeys.deleteByUserId("user-2")).resolves.toBe(2);
expect(await repo.apiKeys.findById("key-1")).toBeNull();
expect(await repo.apiKeys.findById("key-2")).toBeNull();
expect((await repo.apiKeys.findById("key-3"))?.userId).toBe("user-1");
});
});
@@ -0,0 +1,132 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { AuditLogRepository } from "../../../database/repositories/audit-log-repository.js";
describe("AuditLogRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new AuditLogRepository(context, onWrite);
}
it("creates, filters, pages, and lists actions", async () => {
const repo = await createRepository();
await repo.create({
userId: "user-1",
username: "alice",
action: "create_host",
resourceType: "host",
resourceId: "1",
success: true,
timestamp: "2026-06-27T00:00:00.000Z",
});
await repo.create({
userId: "user-2",
username: "bob",
action: "delete_host",
resourceType: "host",
resourceId: "2",
success: false,
timestamp: "2026-06-27T01:00:00.000Z",
});
const page = await repo.listPage({
filters: {
resourceType: "host",
success: false,
startDate: "2026-06-27T00:30:00.000Z",
},
limit: 10,
offset: 0,
});
expect(page.total).toBe(1);
expect(page.logs[0]).toMatchObject({
userId: "user-2",
action: "delete_host",
success: false,
});
expect(await repo.listDistinctActions()).toEqual([
"create_host",
"delete_host",
]);
});
it("deletes logs by user id and only runs write hook for deleted rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create({
userId: "user-1",
username: "alice",
action: "login",
resourceType: "auth",
success: true,
});
await repo.create({
userId: "user-2",
username: "bob",
action: "login",
resourceType: "auth",
success: true,
});
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(2);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(3);
expect(
(
await repo.listPage({
filters: {},
limit: 10,
offset: 0,
})
).logs.map((log) => log.userId),
).toEqual(["user-2"]);
});
});
@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { C2sTunnelPresetRepository } from "../../../database/repositories/c2s-tunnel-preset-repository.js";
describe("C2sTunnelPresetRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new C2sTunnelPresetRepository(context, onWrite);
}
it("creates and lists presets ordered by name", async () => {
const repo = await createRepository();
await repo.createForUser("user-1", {
name: "Zulu",
config: "[]",
platform: "linux",
computerName: "workstation",
});
await repo.createForUser("user-1", { name: "Alpha", config: "[]" });
await repo.createForUser("user-2", { name: "Other", config: "[]" });
const presets = await repo.listByUserId("user-1");
expect(presets.map((preset) => preset.name)).toEqual(["Alpha", "Zulu"]);
expect(presets[1]).toMatchObject({
platform: "linux",
computerName: "workstation",
});
});
it("finds, updates, and deletes user-owned presets", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const preset = await repo.createForUser("user-1", {
name: "Home",
config: "[]",
});
expect(writeCount).toBe(1);
expect(await repo.findByIdForUser("user-2", preset.id)).toBeNull();
expect(await repo.hasNameForUser("user-1", "Home")).toBe(true);
expect(await repo.hasNameForUser("user-1", "Home", preset.id)).toBe(false);
const updated = await repo.updateForUser("user-1", preset.id, {
name: "Renamed",
platform: "darwin",
});
expect(updated).toMatchObject({
id: preset.id,
name: "Renamed",
platform: "darwin",
});
expect(writeCount).toBe(2);
expect(
await repo.updateForUser("user-2", preset.id, { name: "Nope" }),
).toBeNull();
expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", preset.id)).toBe(false);
expect(await repo.deleteForUser("user-1", preset.id)).toBe(true);
expect(writeCount).toBe(3);
});
it("deletes all presets for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.createForUser("user-1", { name: "One", config: "[]" });
await repo.createForUser("user-1", { name: "Two", config: "[]" });
await repo.createForUser("user-2", { name: "Other", config: "[]" });
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.listByUserId("user-1")).toEqual([]);
expect(
(await repo.listByUserId("user-2")).map((preset) => preset.name),
).toEqual(["Other"]);
expect(writeCount).toBe(4);
});
});
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { CommandHistoryRepository } from "../../../database/repositories/command-history-repository.js";
describe("CommandHistoryRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
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');
`);
return new CommandHistoryRepository(context, onWrite);
}
it("creates and lists unique commands by latest execution", async () => {
const repo = await createRepository();
await repo.create("user-1", 1, "ls", "2026-06-27T00:00:00.000Z");
await repo.create("user-1", 1, "pwd", "2026-06-27T01:00:00.000Z");
await repo.create("user-1", 1, "ls", "2026-06-27T02:00:00.000Z");
await repo.create("user-2", 3, "whoami", "2026-06-27T03:00:00.000Z");
expect(await repo.listUniqueCommandsForHost("user-1", 1)).toEqual([
"ls",
"pwd",
]);
expect(await repo.listCommandsForHost("user-1", 1)).toEqual([
"ls",
"pwd",
"ls",
]);
});
it("deletes commands by command, host, host list, and user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create("user-1", 1, "ls");
await repo.create("user-1", 1, "ls");
await repo.create("user-1", 2, "pwd");
await repo.create("user-2", 3, "whoami");
expect(writeCount).toBe(4);
expect(await repo.deleteCommandForHost("user-1", 1, "missing")).toBe(0);
expect(writeCount).toBe(4);
expect(await repo.deleteCommandForHost("user-1", 1, "ls")).toBe(2);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserAndHost("user-1", 2)).toBe(1);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([3])).toBe(1);
expect(writeCount).toBe(7);
await repo.create("user-1", 1, "date");
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(9);
});
});
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { DashboardServiceLinkRepository } from "../../../database/repositories/dashboard-service-link-repository.js";
describe("DashboardServiceLinkRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new DashboardServiceLinkRepository(context, onWrite);
}
it("creates and lists links ordered by order then id", async () => {
const repo = await createRepository();
const first = await repo.createForUser(
"user-1",
{ label: "Docs", url: "https://docs.example.com" },
"2026-06-27T00:00:00.000Z",
);
const second = await repo.createForUser("user-1", {
label: "Status",
url: "https://status.example.com",
});
await repo.createForUser("user-2", {
label: "Other",
url: "https://other.example.com",
});
expect(first).toMatchObject({
userId: "user-1",
label: "Docs",
order: 0,
createdAt: "2026-06-27T00:00:00.000Z",
});
expect(second.order).toBe(1);
expect(
(await repo.listByUserId("user-1")).map((link) => link.label),
).toEqual(["Docs", "Status"]);
});
it("finds, updates, and deletes a user-owned link", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const link = await repo.createForUser("user-1", {
label: "Docs",
url: "https://docs.example.com",
});
expect(writeCount).toBe(1);
expect(await repo.findByIdForUser("user-2", link.id)).toBeNull();
const updated = await repo.updateForUser("user-1", link.id, {
label: "Docs renamed",
});
expect(updated).toMatchObject({
id: link.id,
label: "Docs renamed",
url: "https://docs.example.com",
});
expect(writeCount).toBe(2);
expect(await repo.updateForUser("user-2", link.id, { label: "Nope" })).toBe(
null,
);
expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", link.id)).toBe(false);
expect(await repo.deleteForUser("user-1", link.id)).toBe(true);
expect(writeCount).toBe(3);
});
it("deletes all dashboard links for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.createForUser("user-1", {
label: "Docs",
url: "https://docs.example.com",
});
await repo.createForUser("user-1", {
label: "Status",
url: "https://status.example.com",
});
await repo.createForUser("user-2", {
label: "Other",
url: "https://other.example.com",
});
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.listByUserId("user-1")).toEqual([]);
expect(
(await repo.listByUserId("user-2")).map((link) => link.label),
).toEqual(["Other"]);
expect(writeCount).toBe(4);
});
});
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { DismissedAlertRepository } from "../../../database/repositories/dismissed-alert-repository.js";
describe("DismissedAlertRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new DismissedAlertRepository(context, onWrite);
}
it("creates, lists, and finds dismissed alerts by user", async () => {
const repo = await createRepository();
await repo.create("user-1", "alert-1");
await repo.create("user-1", "alert-2");
await repo.create("user-2", "alert-3");
expect(await repo.listAlertIdsByUserId("user-1")).toEqual([
"alert-1",
"alert-2",
]);
expect((await repo.findForUser("user-1", "alert-1"))?.alertId).toBe(
"alert-1",
);
expect(await repo.findForUser("user-1", "alert-3")).toBeNull();
expect(
(await repo.listByUserId("user-1")).map((row) => row.alertId),
).toEqual(["alert-1", "alert-2"]);
});
it("creates import alerts without duplicating user alert pairs", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(
repo.createForImport("user-1", "alert-1", "2026-01-01T00:00:00.000Z"),
).resolves.toBe(true);
await expect(
repo.createForImport("user-1", "alert-1", "2026-01-02T00:00:00.000Z"),
).resolves.toBe(false);
const alerts = await repo.listByUserId("user-1");
expect(alerts).toHaveLength(1);
expect(alerts[0]).toMatchObject({
alertId: "alert-1",
dismissedAt: "2026-01-01T00:00:00.000Z",
});
expect(writeCount).toBe(1);
});
it("deletes dismissed alerts and only triggers writes for changed rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create("user-1", "alert-1");
await repo.create("user-1", "alert-2");
await repo.create("user-2", "alert-3");
expect(writeCount).toBe(3);
expect(await repo.deleteForUser("user-1", "missing")).toBe(false);
expect(writeCount).toBe(3);
expect(await repo.deleteForUser("user-1", "alert-1")).toBe(true);
expect(writeCount).toBe(4);
expect(await repo.listAlertIdsByUserId("user-1")).toEqual(["alert-2"]);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserId("user-1")).toBe(0);
expect(writeCount).toBe(5);
});
});
@@ -0,0 +1,103 @@
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",
);
});
});
@@ -0,0 +1,232 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { FileManagerBookmarkRepository } from "../../../database/repositories/file-manager-bookmark-repository.js";
describe("FileManagerBookmarkRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
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');
`);
return new FileManagerBookmarkRepository(context, onWrite);
}
it("upserts and lists recent files by last-opened time", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertRecent(
"user-1",
{ hostId: 1, path: "/var/log/app.log" },
"2026-01-01T00:00:00.000Z",
);
await repo.upsertRecent(
"user-1",
{ hostId: 1, path: "/opt/app/config.json", name: "Config" },
"2026-01-02T00:00:00.000Z",
);
await repo.upsertRecent(
"user-1",
{ hostId: 1, path: "/var/log/app.log" },
"2026-01-03T00:00:00.000Z",
);
const recent = await repo.listRecentForHost("user-1", 1);
expect(recent.map((entry) => entry.path)).toEqual([
"/var/log/app.log",
"/opt/app/config.json",
]);
expect(recent[0].lastOpened).toBe("2026-01-03T00:00:00.000Z");
expect(recent[0].name).toBe("app.log");
expect(writeCount).toBe(3);
expect(
await repo.deleteRecentForHostPath("user-1", {
hostId: 1,
path: "/var/log/app.log",
}),
).toBe(1);
expect(writeCount).toBe(4);
});
it("creates pinned files and shortcuts without duplicating paths", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.createPinned(
"user-1",
{ hostId: 1, path: "/srv/www", name: "Web" },
"2026-01-01T00:00:00.000Z",
),
).toBe(true);
expect(
await repo.createPinned("user-1", { hostId: 1, path: "/srv/www" }),
).toBe(false);
expect((await repo.listPinnedForHost("user-1", 1))[0]).toMatchObject({
name: "Web",
path: "/srv/www",
});
expect(
await repo.createShortcut(
"user-1",
{ hostId: 1, path: "/etc/nginx" },
"2026-01-02T00:00:00.000Z",
),
).toBe(true);
expect(
await repo.createShortcut("user-1", { hostId: 1, path: "/etc/nginx" }),
).toBe(false);
expect((await repo.listShortcutsForHost("user-1", 1))[0]).toMatchObject({
name: "nginx",
path: "/etc/nginx",
});
expect(writeCount).toBe(2);
expect(
await repo.deletePinnedForHostPath("user-1", {
hostId: 1,
path: "/srv/www",
}),
).toBe(1);
expect(
await repo.deleteShortcutForHostPath("user-1", {
hostId: 1,
path: "/etc/nginx",
}),
).toBe(1);
expect(writeCount).toBe(4);
});
it("creates import bookmarks without duplicating user path/name pairs", async () => {
const repo = await createRepository();
await expect(
repo.createRecentForImport(
"user-1",
{ hostId: 1, path: "/tmp/a.txt", name: "A" },
"2026-01-01T00:00:00.000Z",
),
).resolves.toBe(true);
await expect(
repo.createRecentForImport("user-1", {
hostId: 2,
path: "/tmp/a.txt",
name: "A",
}),
).resolves.toBe(false);
await expect(
repo.createPinnedForImport("user-1", {
hostId: 1,
path: "/srv/www",
name: "Web",
}),
).resolves.toBe(true);
await expect(
repo.createPinnedForImport("user-1", {
hostId: 2,
path: "/srv/www",
name: "Web",
}),
).resolves.toBe(false);
await expect(
repo.createShortcutForImport("user-1", {
hostId: 1,
path: "/etc/nginx",
name: "Nginx",
}),
).resolves.toBe(true);
await expect(
repo.createShortcutForImport("user-1", {
hostId: 2,
path: "/etc/nginx",
name: "Nginx",
}),
).resolves.toBe(false);
});
it("deletes bookmarks by user, host, and host list", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertRecent("user-1", { hostId: 1, path: "/one" });
await repo.createPinned("user-1", { hostId: 2, path: "/two" });
await repo.createShortcut("user-2", { hostId: 3, path: "/three" });
expect(writeCount).toBe(3);
expect(await repo.deleteByHostId(1)).toBe(1);
expect(await repo.deleteByHostId(1)).toBe(0);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2])).toBe(1);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserId("user-2")).toBe(1);
expect(await repo.deleteByUserId("user-2")).toBe(0);
expect(writeCount).toBe(6);
});
});
@@ -0,0 +1,146 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HomepageItemRepository } from "../../../database/repositories/homepage-item-repository.js";
describe("HomepageItemRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new HomepageItemRepository(context, onWrite);
}
it("creates and lists homepage items by id", async () => {
const repo = await createRepository();
const first = await repo.createForUser(
"user-1",
{ typeId: "clock", title: "Clock", config: "{}" },
"2026-06-27T00:00:00.000Z",
);
const second = await repo.createForUser("user-1", {
typeId: "terminal",
title: null,
config: '{"hostId":1}',
});
await repo.createForUser("user-2", {
typeId: "other",
title: "Other",
config: "{}",
});
expect(first).toMatchObject({
userId: "user-1",
typeId: "clock",
title: "Clock",
config: "{}",
createdAt: "2026-06-27T00:00:00.000Z",
});
expect((await repo.listByUserId("user-1")).map((item) => item.id)).toEqual([
first.id,
second.id,
]);
});
it("finds, updates, and deletes user-owned homepage items", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const item = await repo.createForUser("user-1", {
typeId: "clock",
title: "Clock",
config: "{}",
});
expect(writeCount).toBe(1);
expect(await repo.findByIdForUser("user-2", item.id)).toBeNull();
const updated = await repo.updateForUser(
"user-1",
item.id,
{ title: "Clock renamed", config: '{"timezone":"UTC"}' },
"2026-06-27T01:00:00.000Z",
);
expect(updated).toMatchObject({
id: item.id,
title: "Clock renamed",
config: '{"timezone":"UTC"}',
updatedAt: "2026-06-27T01:00:00.000Z",
});
expect(writeCount).toBe(2);
expect(
await repo.updateForUser("user-2", item.id, { title: "Nope" }),
).toBeNull();
expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", item.id)).toBe(false);
expect(await repo.deleteForUser("user-1", item.id)).toBe(true);
expect(writeCount).toBe(3);
});
it("deletes all homepage items for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.createForUser("user-1", {
typeId: "clock",
title: "Clock",
config: "{}",
});
await repo.createForUser("user-1", {
typeId: "terminal",
title: "Terminal",
config: "{}",
});
await repo.createForUser("user-2", {
typeId: "other",
title: "Other",
config: "{}",
});
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.listByUserId("user-1")).toEqual([]);
expect(
(await repo.listByUserId("user-2")).map((item) => item.title),
).toEqual(["Other"]);
expect(writeCount).toBe(4);
});
});
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HomepageLayoutRepository } from "../../../database/repositories/homepage-layout-repository.js";
describe("HomepageLayoutRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new HomepageLayoutRepository(context, onWrite);
}
it("finds, creates, and updates a layout by user id", async () => {
const repo = await createRepository();
expect(await repo.findByUserId("user-1")).toBeNull();
const created = await repo.upsertForUser(
"user-1",
JSON.stringify({ entries: [{ id: "w1" }], zoom: 1 }),
"2026-06-27T00:00:00.000Z",
);
expect(created).toMatchObject({
userId: "user-1",
layout: '{"entries":[{"id":"w1"}],"zoom":1}',
updatedAt: "2026-06-27T00:00:00.000Z",
});
const updated = await repo.upsertForUser(
"user-1",
JSON.stringify({ entries: [], zoom: 1.25 }),
"2026-06-27T01:00:00.000Z",
);
expect(updated).toMatchObject({
id: created.id,
userId: "user-1",
layout: '{"entries":[],"zoom":1.25}',
updatedAt: "2026-06-27T01:00:00.000Z",
});
});
it("triggers writes after create and update", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForUser("user-1", "{}");
await repo.upsertForUser("user-1", '{"zoom":2}');
expect(writeCount).toBe(2);
});
it("deletes a layout for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForUser("user-1", '{"zoom":1}');
await repo.upsertForUser("user-2", '{"zoom":2}');
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.findByUserId("user-1")).toBeNull();
expect((await repo.findByUserId("user-2"))?.layout).toBe('{"zoom":2}');
expect(writeCount).toBe(3);
});
});
@@ -0,0 +1,691 @@
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,
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,
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,
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");
const updated = await repo.credentials.updateForUser("user-1", created.id, {
folder: "ops",
tags: "linux,admin",
});
expect(updated?.folder).toBe("ops");
expect(
await repo.credentials.findByIdForUser("user-2", created.id),
).toBeNull();
expect(await repo.credentials.deleteForUser("user-1", created.id)).toBe(
true,
);
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");
await repo.credentials.updateEncryptedForUser("user-1", created.id, {
password: "updated-secret",
});
const updatedRaw = repo.sqlite
.prepare("SELECT password FROM ssh_credentials WHERE id = ?")
.get(created.id) as { password: string };
expect(updatedRaw.password).toBe("user-encrypted-password");
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);
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",
});
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);
});
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]);
const updated = await repo.hosts.updateForUser("user-1", host.id, {
name: "web-1-renamed",
folder: "prod",
});
expect(updated?.name).toBe("web-1-renamed");
expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull();
expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true);
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");
await repo.hosts.updateEncryptedForUser("user-1", created.id, {
password: "updated-secret",
});
const updatedRaw = repo.sqlite
.prepare("SELECT password FROM ssh_data WHERE id = ?")
.get(created.id) as { password: string };
expect(updatedRaw.password).toBe("encrypted-host-password");
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",
});
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);
});
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)).toBe(true);
});
});
@@ -0,0 +1,276 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HostFolderRepository } from "../../../database/repositories/host-folder-repository.js";
describe("HostFolderRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<{
repository: HostFolderRepository;
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
);
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,
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,
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,
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,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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, 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'),
(2, 'user-1', 'prod / api', '#222222', 'box'),
(3, 'user-2', 'prod', '#333333', 'user');
`);
return {
repository: new HostFolderRepository(context, onWrite),
sqlite: context.sqlite!,
};
}
it("renames folders across hosts, credentials, and folder records", async () => {
let writes = 0;
const { repository, sqlite } = await createRepository(() => {
writes += 1;
});
await expect(
repository.renameFolder(
"user-1",
"prod",
"ops",
"2026-01-01T00:00:00.000Z",
),
).resolves.toEqual({ updatedHosts: 2, updatedCredentials: 2 });
expect(
sqlite
.prepare("SELECT folder FROM ssh_data WHERE user_id = ? ORDER BY id")
.all("user-1"),
).toEqual([{ folder: "ops" }, { folder: "ops / api" }]);
expect(
sqlite
.prepare(
"SELECT folder FROM ssh_credentials WHERE user_id = ? ORDER BY id",
)
.all("user-1"),
).toEqual([{ folder: "ops" }, { folder: "ops / api" }]);
expect(
sqlite
.prepare("SELECT name FROM ssh_folders WHERE user_id = ? ORDER BY id")
.all("user-1"),
).toEqual([{ name: "ops" }, { name: "ops / api" }]);
expect(writes).toBe(1);
});
it("lists folders and upserts metadata", async () => {
let writes = 0;
const { repository } = await createRepository(() => {
writes += 1;
});
await expect(repository.listFolders("user-1")).resolves.toHaveLength(2);
await expect(
repository.upsertMetadata(
"user-1",
"prod",
"#abcdef",
"folder",
"2026-02-01T00:00:00.000Z",
),
).resolves.toMatchObject({
created: false,
folder: { color: "#abcdef", icon: "folder" },
});
await expect(
repository.upsertMetadata(
"user-1",
"new",
null,
null,
"2026-03-01T00:00:00.000Z",
),
).resolves.toMatchObject({
created: true,
folder: { name: "new" },
});
expect(writes).toBe(2);
});
it("lists and deletes hosts and folder records in a folder tree", async () => {
let writes = 0;
const { repository, sqlite } = await createRepository(() => {
writes += 1;
});
const hostsToDelete = await repository.listHostsInFolder("user-1", "prod");
expect(hostsToDelete.map((host) => host.id)).toEqual([1, 2]);
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(),
).toEqual([{ id: 3 }]);
expect(writes).toBe(1);
});
it("deletes folder records for a user", async () => {
let writes = 0;
const { repository, sqlite } = 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(),
).toEqual([{ id: 3 }]);
expect(writes).toBe(1);
});
});
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HostHealthRepository } from "../../../database/repositories/host-health-repository.js";
describe("HostHealthRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
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 host_health_checks (
user_id, host_id, checks, interval_seconds, created_at, updated_at
)
VALUES (
'user-1',
1,
'[{"id":"tcp","name":"TCP","type":"tcp","target":"localhost","port":22}]',
300,
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z'
);
`);
return new HostHealthRepository(context, onWrite);
}
it("finds and upserts check configuration", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const existing = await repo.findChecksByUserAndHost("user-1", 1);
expect(existing?.intervalSeconds).toBe(300);
const updated = await repo.upsertChecks(
"user-1",
1,
'[{"id":"http"}]',
60,
"2026-02-01T00:00:00.000Z",
);
expect(updated).toMatchObject({
id: existing?.id,
checks: '[{"id":"http"}]',
intervalSeconds: 60,
updatedAt: "2026-02-01T00:00:00.000Z",
});
const created = await repo.upsertChecks(
"user-2",
2,
'[{"id":"tcp"}]',
120,
"2026-03-01T00:00:00.000Z",
);
expect(created).toMatchObject({
userId: "user-2",
hostId: 2,
checks: '[{"id":"tcp"}]',
intervalSeconds: 120,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
expect(writeCount).toBe(2);
});
it("records and prunes history", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.recordHistory(
"user-1",
1,
[{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }],
1,
"2026-01-01T00:00:00.000Z",
),
).toBe(1);
expect(
await repo.recordHistory(
"user-1",
1,
[{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }],
1,
"2026-01-02T00:00:00.000Z",
),
).toBe(1);
const history = await repo.listHistory("user-1", 1, 10);
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({
userId: "user-1",
hostId: 1,
checkId: "two",
ok: false,
latencyMs: null,
detail: "closed",
});
expect(writeCount).toBe(2);
});
it("deletes all health checks and history for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertChecks("user-2", 2, '[{"id":"tcp"}]', 120);
await repo.recordHistory(
"user-1",
1,
[{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }],
10,
);
await repo.recordHistory(
"user-2",
2,
[{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }],
10,
);
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
checksDeleted: 1,
historyDeleted: 1,
});
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
checksDeleted: 0,
historyDeleted: 0,
});
expect(await repo.findChecksByUserAndHost("user-1", 1)).toBeNull();
expect(await repo.listHistory("user-1", 1, 10)).toEqual([]);
expect((await repo.findChecksByUserAndHost("user-2", 2))?.hostId).toBe(2);
expect(await repo.listHistory("user-2", 2, 10)).toHaveLength(1);
expect(writeCount).toBe(4);
});
});
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HostMetricsHistoryRepository } from "../../../database/repositories/host-metrics-history-repository.js";
describe("HostMetricsHistoryRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
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 host_metrics_history (
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
)
VALUES
(1, '2026-01-01 00:00:00', 10, 20, 30, 100, 200),
(1, '2026-01-02 00:00:00', 11, 21, 31, 101, 201),
(1, '2999-01-01 00:00:00', 12, 22, 32, 102, 202),
(2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999);
`);
return new HostMetricsHistoryRepository(context, onWrite);
}
it("creates and lists metrics history rows by range", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create({
hostId: 1,
cpuPercent: 12,
memPercent: 22,
diskPercent: 32,
netRxBytes: 102,
netTxBytes: 202,
});
const rows = await repo.listRange(
1,
"2026-01-01 00:00:00",
"2026-01-02 23:59:59",
);
expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]);
expect(writeCount).toBe(1);
});
it("prunes old history for a host only", async () => {
const repo = await createRepository();
repo.pruneOlderThan(1, 1);
const rows = await repo.listRange(
1,
"2000-01-01 00:00:00",
"2999-12-31 23:59:59",
);
expect(rows.map((row) => row.ts)).toEqual(["2999-01-01 00:00:00"]);
expect(
await repo.listRange(2, "2026-01-01 00:00:00", "2026-01-03 00:00:00"),
).toHaveLength(1);
});
});
@@ -0,0 +1,141 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { HostMetricsPreferenceRepository } from "../../../database/repositories/host-metrics-preference-repository.js";
describe("HostMetricsPreferenceRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
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 host_metrics_preferences (
user_id, host_id, layout, created_at, updated_at
)
VALUES (
'user-1',
1,
'{"slots":[],"columns":3}',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z'
);
`);
return new HostMetricsPreferenceRepository(context, onWrite);
}
it("finds a saved layout by user and host", async () => {
const repo = await createRepository();
const existing = await repo.findByUserAndHost("user-1", 1);
expect(existing?.layout).toBe('{"slots":[],"columns":3}');
expect(await repo.findByUserAndHost("user-1", 2)).toBeNull();
});
it("updates and inserts layouts with write notifications", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const updated = await repo.upsertLayout(
"user-1",
1,
'{"slots":[{"id":"cpu"}],"columns":2}',
"2026-02-01T00:00:00.000Z",
);
expect(updated).toMatchObject({
id: 1,
layout: '{"slots":[{"id":"cpu"}],"columns":2}',
updatedAt: "2026-02-01T00:00:00.000Z",
});
const created = await repo.upsertLayout(
"user-2",
2,
'{"slots":[{"id":"mem"}],"columns":1}',
"2026-03-01T00:00:00.000Z",
);
expect(created).toMatchObject({
userId: "user-2",
hostId: 2,
layout: '{"slots":[{"id":"mem"}],"columns":1}',
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
expect(writeCount).toBe(2);
});
it("updates host stats config for the owning user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(
repo.updateHostStatsConfig(
"user-1",
1,
'{"enabledWidgets":["cpu","memory"]}',
),
).resolves.toBe(true);
await expect(
repo.updateHostStatsConfig("user-2", 1, '{"enabledWidgets":["disk"]}'),
).resolves.toBe(false);
expect(writeCount).toBe(1);
});
it("deletes all metric preferences for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertLayout("user-2", 2, '{"slots":["mem"]}');
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
expect((await repo.findByUserAndHost("user-2", 2))?.layout).toBe(
'{"slots":["mem"]}',
);
expect(writeCount).toBe(2);
});
});
@@ -0,0 +1,495 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { DataCrypto } from "../../../utils/data-crypto.js";
import { HostResolutionRepository } from "../../../database/repositories/host-resolution-repository.js";
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: vi.fn(),
decryptRecord: vi.fn((_tableName, record) => record),
},
}));
describe("HostResolutionRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
vi.mocked(DataCrypto.getUserDataKey).mockReset();
vi.mocked(DataCrypto.decryptRecord).mockClear();
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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,
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,
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,
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
);
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, credential_id,
tunnel_connections
)
VALUES
(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);
`);
return new HostResolutionRepository(context, onWrite);
}
it("loads host and credential rows through the decryption boundary", async () => {
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(
Buffer.from("user-key"),
);
const repository = await createRepository();
await expect(repository.findHostById(1, "user-1")).resolves.toMatchObject({
id: 1,
userId: "user-1",
name: "web",
credentialId: 7,
});
await expect(
repository.findHostByIdForUser(1, "user-1"),
).resolves.toMatchObject({
id: 1,
userId: "user-1",
name: "web",
credentialId: 7,
});
await expect(
repository.findHostByIdForUser(3, "user-1"),
).resolves.toBeNull();
await expect(
repository.findCredentialByIdForUser(7, "user-1"),
).resolves.toMatchObject({
id: 7,
userId: "user-1",
username: "root",
password: "secret",
});
await expect(
repository.findCredentialByIdForOwnerDecryptedAs(7, "user-1", "user-2"),
).resolves.toMatchObject({
id: 7,
userId: "user-1",
username: "root",
password: "secret",
});
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ id: 1 }),
"user-1",
Buffer.from("user-key"),
);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_credentials",
expect.objectContaining({ id: 7 }),
"user-1",
Buffer.from("user-key"),
);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_credentials",
expect.objectContaining({ id: 7 }),
"user-2",
Buffer.from("user-key"),
);
});
it("lists user-owned hosts through the decryption boundary", async () => {
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(
Buffer.from("user-key"),
);
const repository = await createRepository();
const rows = await repository.findHostsByUserId("user-1");
expect(rows.map((row) => row.id)).toEqual([1, 2]);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ id: 1 }),
"user-1",
Buffer.from("user-key"),
);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ id: 2 }),
"user-1",
Buffer.from("user-key"),
);
});
it("lists raw own and shared host rows for access list assembly", async () => {
const repository = await createRepository();
const rows = await repository.listHostRowsForAccessList("user-2", [
{ hostId: 1, permissionLevel: "execute", expiresAt: null },
{ hostId: 3, permissionLevel: "view", expiresAt: null },
{ hostId: 999, permissionLevel: "view", expiresAt: null },
]);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
id: 3,
userId: "user-2",
ownerId: "user-2",
isShared: false,
permissionLevel: undefined,
expiresAt: undefined,
});
expect(rows[1]).toMatchObject({
id: 1,
userId: "user-1",
ownerId: "user-1",
isShared: true,
permissionLevel: "execute",
expiresAt: null,
});
expect(DataCrypto.decryptRecord).not.toHaveBeenCalled();
});
it("loads host owner metadata without decrypting host data", async () => {
const repository = await createRepository();
await expect(repository.findHostOwnerId(1)).resolves.toBe("user-1");
await expect(repository.findHostOwnerId(999)).resolves.toBeNull();
await expect(repository.isHostOwnedByUser(1, "user-1")).resolves.toBe(true);
await expect(repository.isHostOwnedByUser(1, "user-2")).resolves.toBe(
false,
);
expect(DataCrypto.decryptRecord).not.toHaveBeenCalled();
});
it("loads host update state without decrypting host data", async () => {
const repository = await createRepository();
await expect(repository.findHostUpdateState(1)).resolves.toEqual({
userId: "user-1",
credentialId: 7,
rdpCredentialId: null,
vncCredentialId: null,
telnetCredentialId: null,
vaultProfileId: null,
authType: "password",
});
await expect(repository.findHostUpdateState(999)).resolves.toBeNull();
expect(DataCrypto.decryptRecord).not.toHaveBeenCalled();
});
it("lists hosts using a credential through the decryption boundary", async () => {
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(
Buffer.from("user-key"),
);
const repository = await createRepository();
const rows = await repository.listHostsUsingCredentialForUser("user-1", 7);
expect(rows.map((row) => row.id)).toEqual([1]);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ id: 1 }),
"user-1",
Buffer.from("user-key"),
);
});
it("lists all hosts through each owner decryption boundary", async () => {
vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) =>
Buffer.from(`${userId}-key`),
);
const repository = await createRepository();
const rows = await repository.listAllHosts();
expect(rows.map((row) => row.id)).toEqual([1, 2, 3]);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ id: 1 }),
"user-1",
Buffer.from("user-1-key"),
);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ id: 3 }),
"user-2",
Buffer.from("user-2-key"),
);
});
it("lists tunnel-enabled hosts with tunnel data through each owner decryption boundary", async () => {
vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) =>
Buffer.from(`${userId}-key`),
);
const repository = await createRepository();
const rows = await repository.listHostsWithTunnelConnections();
expect(rows.map((row) => row.id)).toEqual([1, 3]);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ id: 1 }),
"user-1",
Buffer.from("user-1-key"),
);
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ id: 3 }),
"user-2",
Buffer.from("user-2-key"),
);
});
it("skips owner-scoped host list rows when that user's data is locked", async () => {
vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) =>
userId === "user-1" ? Buffer.from("user-1-key") : null,
);
const repository = await createRepository();
const rows = await repository.listAllHosts();
expect(rows.map((row) => row.id)).toEqual([1, 2]);
expect(DataCrypto.decryptRecord).not.toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ id: 3 }),
expect.any(String),
expect.any(Buffer),
);
});
it("loads host key verification metadata without decrypting credentials", async () => {
const repository = await createRepository();
const row = await repository.findHostKeyVerificationData(1);
expect(row).toMatchObject({
hostKeyFingerprint: null,
hostKeyType: null,
hostKeyAlgorithm: "sha256",
hostKeyChangedCount: 0,
name: "web",
});
expect(DataCrypto.decryptRecord).not.toHaveBeenCalled();
});
it("stores and updates host key verification metadata through the write boundary", async () => {
const onWrite = vi.fn();
const repository = await createRepository(onWrite);
await repository.storeHostKey(
1,
"fingerprint-1",
"ssh-rsa",
"sha256",
"t1",
);
await expect(
repository.findHostKeyVerificationData(1),
).resolves.toMatchObject({
hostKeyFingerprint: "fingerprint-1",
hostKeyType: "ssh-rsa",
hostKeyAlgorithm: "sha256",
hostKeyChangedCount: 0,
});
await repository.touchHostKeyLastVerified(1, "t2");
await repository.updateHostKey(
1,
"fingerprint-2",
"ssh-ed25519",
"sha256",
0,
"t3",
);
await expect(
repository.findHostKeyVerificationData(1),
).resolves.toMatchObject({
hostKeyFingerprint: "fingerprint-2",
hostKeyType: "ssh-ed25519",
hostKeyAlgorithm: "sha256",
hostKeyChangedCount: 1,
});
expect(onWrite).toHaveBeenCalledTimes(3);
});
it("returns null when user data is locked", async () => {
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(null);
const repository = await createRepository();
await expect(repository.findHostById(1, "user-1")).resolves.toBeNull();
await expect(
repository.findHostByIdForUser(1, "user-1"),
).resolves.toBeNull();
await expect(repository.findHostsByUserId("user-1")).resolves.toEqual([]);
await expect(
repository.listHostsUsingCredentialForUser("user-1", 7),
).resolves.toEqual([]);
await expect(
repository.findCredentialByIdForUser(7, "user-1"),
).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();
});
});
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { NetworkTopologyRepository } from "../../../database/repositories/network-topology-repository.js";
describe("NetworkTopologyRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new NetworkTopologyRepository(context, onWrite);
}
it("finds, creates, and updates topology by user id", async () => {
const repo = await createRepository();
expect(await repo.findByUserId("user-1")).toBeNull();
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }),
"2026-06-27T00:00:00.000Z",
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[{"id":"host-1"}],"edges":[]}',
updatedAt: "2026-06-27T00:00:00.000Z",
});
await repo.upsertForUser(
"user-1",
JSON.stringify({ nodes: [], edges: [{ id: "edge-1" }] }),
"2026-06-27T01:00:00.000Z",
);
expect(await repo.findByUserId("user-1")).toMatchObject({
userId: "user-1",
topology: '{"nodes":[],"edges":[{"id":"edge-1"}]}',
updatedAt: "2026-06-27T01:00:00.000Z",
});
});
it("deletes topology and only triggers writes for changed rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForUser("user-1", "{}");
await repo.upsertForUser("user-1", '{"nodes":[]}');
expect(writeCount).toBe(2);
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(2);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(3);
expect(await repo.findByUserId("user-1")).toBeNull();
});
});
@@ -0,0 +1,146 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { OpenTabRepository } from "../../../database/repositories/open-tab-repository.js";
describe("OpenTabRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new OpenTabRepository(context, onWrite);
}
it("lists recent tabs ordered by tab order", async () => {
const repo = await createRepository();
await repo.upsertForUser(
"user-1",
{
id: "tab-old",
tabType: "terminal",
label: "Old",
tabOrder: 1,
},
"2026-06-27T00:00:00.000Z",
);
await repo.upsertForUser(
"user-1",
{
id: "tab-new",
tabType: "stats",
label: "New",
tabOrder: 0,
},
"2026-06-27T01:00:00.000Z",
);
await repo.upsertForUser(
"user-2",
{
id: "other",
tabType: "terminal",
label: "Other",
tabOrder: 0,
},
"2026-06-27T02:00:00.000Z",
);
expect(
(await repo.listRecentForUser("user-1", "2026-06-27T00:30:00.000Z")).map(
(tab) => tab.id,
),
).toEqual(["tab-new"]);
});
it("upserts tabs and preserves backend session when omitted", async () => {
const repo = await createRepository();
await repo.upsertForUser("user-1", {
id: "tab-1",
tabType: "terminal",
hostId: 1,
label: "Server",
tabOrder: 0,
backendSessionId: "session-1",
});
await repo.upsertForUser("user-1", {
id: "tab-1",
tabType: "terminal",
hostId: 2,
label: "Server renamed",
tabOrder: 1,
});
expect(
(await repo.listRecentForUser("user-1", "2000-01-01T00:00:00.000Z"))[0],
).toMatchObject({
id: "tab-1",
hostId: 2,
label: "Server renamed",
tabOrder: 1,
backendSessionId: "session-1",
});
});
it("replaces, updates, and deletes tabs for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.replaceForUser("user-1", [
{ id: "tab-1", tabType: "terminal", label: "One", tabOrder: 1 },
{ id: "tab-2", tabType: "settings", label: "Two", tabOrder: 0 },
]);
expect(
(await repo.listRecentForUser("user-1", "2000-01-01T00:00:00.000Z")).map(
(tab) => tab.id,
),
).toEqual(["tab-2", "tab-1"]);
expect(
await repo.updateForUser("user-1", "missing", { label: "Missing" }),
).toBe(false);
expect(
await repo.updateForUser("user-1", "tab-1", { label: "Renamed" }),
).toBe(true);
expect(await repo.deleteForUser("user-1", "tab-2")).toBe(1);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(await repo.deleteByUserId("user-1")).toBe(0);
expect(writeCount).toBe(4);
});
});
@@ -0,0 +1,133 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { OpksshTokenRepository } from "../../../database/repositories/opkssh-token-repository.js";
describe("OpksshTokenRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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)
);
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 opkssh_tokens (
user_id, host_id, ssh_cert, private_key, email, expires_at
)
VALUES
('user-1', 1, 'cert-1', 'key-1', 'alice@example.com', '2099-01-01T00:00:00.000Z'),
('user-1', 2, 'cert-2', 'key-2', 'alice2@example.com', '2099-01-01T00:00:00.000Z'),
('user-2', 3, 'cert-3', 'key-3', 'bob@example.com', '2099-01-01T00:00:00.000Z');
`);
return new OpksshTokenRepository(context, onWrite);
}
it("finds and upserts a token by user and host", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const existing = await repo.findByUserAndHost("user-1", 1);
expect(existing?.sshCert).toBe("cert-1");
await repo.upsert({
userId: "user-1",
hostId: 1,
sshCert: "new-cert",
privateKey: "new-key",
email: "new@example.com",
sub: "sub",
issuer: "issuer",
audience: "aud",
expiresAt: "2099-02-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
});
const updated = await repo.findByUserAndHost("user-1", 1);
expect(updated).toMatchObject({
sshCert: "new-cert",
privateKey: "new-key",
email: "new@example.com",
sub: "sub",
issuer: "issuer",
audience: "aud",
expiresAt: "2099-02-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
});
expect(writeCount).toBe(1);
});
it("updates last-used and deletes one token", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"),
).toBe(true);
expect(await repo.updateLastUsed("missing", 1)).toBe(false);
expect((await repo.findByUserAndHost("user-1", 1))?.lastUsed).toBe(
"2026-01-02T00:00:00.000Z",
);
expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(true);
expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(false);
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
expect(writeCount).toBe(2);
});
it("deletes tokens by user id", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteByUserId("user-1")).toBe(2);
expect(await repo.deleteByUserId("user-1")).toBe(0);
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
expect(await repo.findByUserAndHost("user-2", 3)).not.toBeNull();
expect(writeCount).toBe(1);
});
});
@@ -0,0 +1,562 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { RbacAccessRepository } from "../../../database/repositories/rbac-access-repository.js";
describe("RbacAccessRepository", () => {
let adapter: TestSqliteDatabase | null = null;
const activeAccessTime = "2026-06-26T12:00:00.000Z";
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
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_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');
INSERT INTO host_access (
id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
)
VALUES
(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');
`);
return new RbacAccessRepository(context, onWrite);
}
it("lists host access with user and role target metadata", async () => {
const repo = await createRepository();
const accessList = await repo.listHostAccess(42);
expect(accessList).toMatchObject([
{
id: 2,
targetType: "role",
userId: null,
roleId: 7,
username: null,
roleName: "ops",
roleDisplayName: "Operations",
grantedByUsername: "admin",
},
{
id: 1,
targetType: "user",
userId: "user-1",
roleId: null,
username: "alice",
roleName: null,
roleDisplayName: null,
grantedByUsername: "admin",
},
]);
});
it("lists snippet access with user and role target metadata", async () => {
const repo = await createRepository();
const accessList = await repo.listSnippetAccess(99);
expect(accessList.map((access) => access.targetType)).toEqual([
"role",
"user",
]);
expect(accessList[0]).toMatchObject({
id: 4,
roleId: 7,
roleName: "ops",
grantedByUsername: "admin",
});
expect(accessList[1]).toMatchObject({
id: 3,
userId: "user-1",
username: "alice",
grantedByUsername: "admin",
});
});
it("lists shared hosts for direct and role access", async () => {
const repo = await createRepository();
const sharedHosts = await repo.listSharedHosts(
"user-1",
[7],
activeAccessTime,
);
expect(sharedHosts).toMatchObject([
{
id: 42,
name: "prod",
ip: "10.0.0.42",
ownerUsername: "owner",
permissionLevel: "view",
},
{
id: 42,
name: "prod",
ip: "10.0.0.42",
ownerUsername: "owner",
permissionLevel: "view",
},
]);
});
it("lists visible host access entries for host list access checks", async () => {
const repo = await createRepository();
await expect(
repo.listVisibleHostAccessEntries("user-1", [7], activeAccessTime),
).resolves.toEqual([
{
hostId: 42,
permissionLevel: "view",
expiresAt: "2026-06-27T00:00:00.000Z",
},
{
hostId: 42,
permissionLevel: "view",
expiresAt: null,
},
]);
});
it("lists role host access credential sources for role assignment", async () => {
const repo = await createRepository();
await expect(repo.listRoleHostAccessCredentialSources(7)).resolves.toEqual([
{
hostAccessId: 2,
credentialId: 123,
rdpCredentialId: 124,
vncCredentialId: 125,
telnetCredentialId: 126,
hostId: 42,
hostOwnerId: "owner-1",
},
]);
});
it("finds shared secrets per protocol and host access owner", async () => {
const repo = await createRepository();
await expect(
repo.findSharedSecretForHostUserProtocol(42, "user-1", "ssh"),
).resolves.toMatchObject({
id: 8,
hostAccessId: 2,
protocol: "ssh",
sourceType: "credential",
originalCredentialId: 123,
targetUserId: "user-1",
encryptedUsername: "enc-user",
encryptedAuthType: "enc-auth",
});
await expect(
repo.findSharedSecretForHostUserProtocol(42, "user-1", "rdp"),
).resolves.toMatchObject({
id: 9,
protocol: "rdp",
sourceType: "inline",
originalCredentialId: null,
});
await expect(
repo.findSharedSecretForHostUserProtocol(42, "user-1", "vnc"),
).resolves.toBeNull();
await expect(
repo.findSharedSecretForHostUserProtocol(99, "user-1", "ssh"),
).resolves.toBeNull();
await expect(repo.findHostAccessOwnerId(2)).resolves.toBe("owner-1");
await expect(repo.findHostAccessOwnerId(999)).resolves.toBeNull();
});
it("lists active grants, finds grants by id and updates grant level/expiry", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const grants = await repo.listActiveHostAccessGrants(42, activeAccessTime);
expect(grants.map((grant) => grant.id).sort()).toEqual([1, 2]);
// Host 44's only grant expired before activeAccessTime.
await expect(
repo.listActiveHostAccessGrants(44, activeAccessTime),
).resolves.toEqual([]);
await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({
id: 1,
hostId: 42,
permissionLevel: "view",
});
await expect(repo.findHostAccessById(1, 99)).resolves.toBeNull();
await expect(
repo.updateHostAccessGrant(1, 42, {
permissionLevel: "manage",
expiresAt: "2026-07-01T00:00:00.000Z",
}),
).resolves.toBe(true);
await expect(repo.findHostAccessById(1, 42)).resolves.toMatchObject({
permissionLevel: "manage",
expiresAt: "2026-07-01T00:00:00.000Z",
});
await expect(
repo.updateHostAccessGrant(999, 42, { permissionLevel: "view" }),
).resolves.toBe(false);
expect(writeCount).toBe(1);
});
it("lists shared snippets and preserves route-level direct-over-role behavior", async () => {
const repo = await createRepository();
const sharedSnippets = await repo.listSharedSnippets(
"user-1",
[7],
activeAccessTime,
);
expect(sharedSnippets).toHaveLength(1);
expect(sharedSnippets[0]).toMatchObject({
id: 99,
name: "deploy",
ownerUsername: "owner",
permissionLevel: "view",
});
});
it("lists visible shared snippets for the main snippets route", async () => {
const repo = await createRepository();
const sharedSnippets = await repo.listVisibleSharedSnippets(
"user-1",
[7],
activeAccessTime,
);
expect(sharedSnippets.map((snippet) => snippet.id)).toEqual([99, 99]);
expect(sharedSnippets[0]).toMatchObject({
userId: "owner-1",
name: "deploy",
content: "echo deploy",
ownerUsername: "owner",
});
});
it("finds an accessible shared snippet for direct or role access", async () => {
const repo = await createRepository();
await expect(
repo.findAccessibleSharedSnippet(99, "user-2", [7], activeAccessTime),
).resolves.toMatchObject({
id: 99,
userId: "owner-1",
name: "deploy",
content: "echo deploy",
ownerUsername: "owner",
permissionLevel: "view",
hostFilter: null,
});
await expect(
repo.findAccessibleSharedSnippet(
99,
"user-2",
[7],
"2026-06-28T00:00:00.000Z",
),
).resolves.toBeNull();
});
it("upserts host access and updates overrides", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const updated = await repo.upsertHostAccess({
hostId: 42,
targetType: "user",
targetUserId: "user-1",
grantedBy: "admin",
permissionLevel: "view",
expiresAt: "2026-06-28T00:00:00.000Z",
});
expect(updated).toEqual({ id: 1, created: false });
expect(
(await repo.listHostAccess(42)).find((row) => row.id === 1),
).toMatchObject({
expiresAt: "2026-06-28T00:00:00.000Z",
});
const created = await repo.upsertHostAccess({
hostId: 43,
targetType: "role",
targetRoleId: 7,
grantedBy: "admin",
permissionLevel: "view",
expiresAt: null,
});
expect(created.created).toBe(true);
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,
).toBe("2026-06-26T03:00:00.000Z");
await repo.revokeHostAccess(1, 42);
expect(await repo.findDirectHostAccess(42, "user-1")).toBeNull();
expect(writeCount).toBe(5);
});
it("finds active host access and deletes expired host access", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.findActiveHostAccess(
42,
"user-1",
[7],
"2026-06-26T12:00:00.000Z",
),
).toMatchObject({ id: 1 });
expect(
await repo.findActiveHostAccess(
44,
"user-1",
[],
"2026-06-26T12:00:00.000Z",
),
).toBeNull();
expect(await repo.deleteExpiredHostAccess("2026-06-26T12:00:00.000Z")).toBe(
1,
);
expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull();
expect(writeCount).toBe(1);
});
it("deletes host access for a host and only saves when rows changed", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteHostAccessForHost(42)).toBe(2);
expect(await repo.listHostAccess(42)).toEqual([]);
expect(writeCount).toBe(1);
expect(await repo.deleteHostAccessForHost(42)).toBe(0);
expect(writeCount).toBe(1);
});
it("deletes host access for multiple hosts", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteHostAccessForHosts([])).toBe(0);
expect(writeCount).toBe(0);
expect(await repo.deleteHostAccessForHosts([42, 44])).toBe(3);
expect(await repo.listHostAccess(42)).toEqual([]);
expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull();
expect(writeCount).toBe(1);
});
it("deletes host access that references a user directly or as grantor", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(3);
expect(await repo.listHostAccess(42)).toEqual([]);
expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull();
expect(writeCount).toBe(1);
expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(0);
expect(writeCount).toBe(1);
});
it("upserts and revokes snippet access", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const updated = await repo.upsertSnippetAccess({
snippetId: 99,
targetType: "user",
targetUserId: "user-1",
grantedBy: "admin",
expiresAt: "2026-06-28T00:00:00.000Z",
});
expect(updated).toEqual({ id: 3, created: false });
expect(
(await repo.listSnippetAccess(99)).find((row) => row.id === 3),
).toMatchObject({ expiresAt: "2026-06-28T00:00:00.000Z" });
const created = await repo.upsertSnippetAccess({
snippetId: 100,
targetType: "role",
targetRoleId: 7,
grantedBy: "admin",
expiresAt: null,
});
expect(created.created).toBe(true);
await repo.revokeSnippetAccess(3, 99);
expect((await repo.listSnippetAccess(99)).map((row) => row.id)).toEqual([
4,
]);
expect(writeCount).toBe(3);
});
});
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js";
describe("RecentActivityRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
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
);
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 recent_activity (id, user_id, type, host_id, host_name, timestamp)
VALUES
(1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'),
(2, 'user-1', 'disconnect', 2, 'two', '2026-06-26T00:01:00.000Z'),
(3, 'user-2', 'connect', 3, 'other', '2026-06-26T00:02:00.000Z');
`);
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(() => {
writeCount += 1;
});
expect(
(await repository.listByUserId("user-1", 2)).map((row) => row.id),
).toEqual([2, 1]);
const created = await repository.create({
userId: "user-1",
type: "terminal",
hostId: 1,
hostName: "one",
timestamp: "2026-06-26T00:03:00.000Z",
});
expect(created).toMatchObject({
userId: "user-1",
type: "terminal",
hostId: 1,
});
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"),
).toEqual([{ id: created.id }, { id: 2 }]);
expect(writeCount).toBe(2);
});
it("deletes activity by user id and only triggers writes for changed rows", async () => {
let writeCount = 0;
const { repository: repo } = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(0);
expect(await repo.deleteByUserId("user-1")).toBe(2);
expect(writeCount).toBe(1);
expect(await repo.deleteByUserId("user-1")).toBe(0);
expect(writeCount).toBe(1);
});
it("deletes activity by host id and host id list", async () => {
let writeCount = 0;
const { repository: repo } = await createRepository(() => {
writeCount += 1;
});
expect(await repo.deleteByHostId(1)).toBe(1);
expect(await repo.deleteByHostId(1)).toBe(0);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2, 3])).toBe(2);
expect(writeCount).toBe(2);
});
});
@@ -0,0 +1,278 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { RoleRepository } from "../../../database/repositories/role-repository.js";
describe("RoleRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'user', 'hash', 0, 0);
`);
return new RoleRepository(context, onWrite);
}
it("creates, lists, updates, and finds roles", async () => {
const repo = await createRepository();
const roleId = await repo.createRole({
name: "ops",
displayName: "Operations",
description: "Ops access",
isSystem: false,
permissions: null,
});
expect((await repo.findRoleByName("ops"))?.id).toBe(roleId);
expect((await repo.findRoleById(roleId))?.displayName).toBe("Operations");
await repo.updateRole(roleId, {
displayName: "Ops",
description: null,
updatedAt: "2026-06-26T00:00:00.000Z",
});
const roles = await repo.listRoles();
expect(roles.map((role) => role.name)).toEqual(["ops"]);
expect(roles[0].displayName).toBe("Ops");
expect(roles[0].description).toBeNull();
});
it("assigns, lists, and removes user roles", async () => {
const repo = await createRepository();
const roleId = await repo.createRole({
name: "ops",
displayName: "Operations",
isSystem: false,
permissions: JSON.stringify(["hosts.read", "hosts.*"]),
});
await repo.assignRoleToUser({
userId: "user-1",
roleId,
grantedBy: "admin",
});
expect(await repo.findUserRole("user-1", roleId)).not.toBeNull();
expect(await repo.listUserRoleIds("user-1")).toEqual([roleId]);
expect(await repo.listRoleUserIds(roleId)).toEqual(["user-1"]);
expect((await repo.listUserRoles("user-1"))[0]).toMatchObject({
roleId,
roleName: "ops",
roleDisplayName: "Operations",
isSystem: false,
});
expect(await repo.listUserRolePermissions("user-1")).toEqual([
{ permissions: JSON.stringify(["hosts.read", "hosts.*"]) },
]);
expect(await repo.userHasAnyRoleName("user-1", ["admin", "ops"])).toBe(
true,
);
expect(await repo.userHasAnyRoleName("user-1", ["admin"])).toBe(false);
expect(await repo.userHasAnyRoleName("user-1", [])).toBe(false);
await repo.removeRoleFromUser("user-1", roleId);
expect(await repo.findUserRole("user-1", roleId)).toBeNull();
});
it("assigns roles by name", async () => {
const repo = await createRepository();
const roleId = await repo.createRole({
name: "user",
displayName: "User",
isSystem: true,
permissions: null,
});
expect(
await repo.assignRoleNameToUser({
userId: "user-1",
roleName: "missing",
grantedBy: "admin",
}),
).toBe(false);
expect(await repo.listUserRoleIds("user-1")).toEqual([]);
expect(
await repo.assignRoleNameToUser({
userId: "user-1",
roleName: "user",
grantedBy: "admin",
}),
).toBe(true);
expect(await repo.listUserRoleIds("user-1")).toEqual([roleId]);
});
it("switches user roles by role name", async () => {
const repo = await createRepository();
const userRoleId = await repo.createRole({
name: "user",
displayName: "User",
isSystem: true,
permissions: null,
});
const adminRoleId = await repo.createRole({
name: "admin",
displayName: "Admin",
isSystem: true,
permissions: null,
});
await repo.assignRoleToUser({
userId: "user-1",
roleId: userRoleId,
grantedBy: "admin",
});
await expect(
repo.switchUserRoleName({
userId: "user-1",
addRoleName: "admin",
removeRoleName: "user",
grantedBy: "admin",
}),
).resolves.toEqual({ added: true, removed: true });
expect(await repo.listUserRoleIds("user-1")).toEqual([adminRoleId]);
await expect(
repo.switchUserRoleName({
userId: "user-1",
addRoleName: "missing",
removeRoleName: "admin",
grantedBy: "admin",
}),
).resolves.toEqual({ added: false, removed: true });
expect(await repo.listUserRoleIds("user-1")).toEqual([]);
});
it("deletes role assignments and returns affected users", async () => {
const repo = await createRepository();
const roleId = await repo.createRole({
name: "ops",
displayName: "Operations",
isSystem: false,
permissions: null,
});
await repo.assignRoleToUser({
userId: "user-1",
roleId,
grantedBy: "admin",
});
const result = await repo.deleteRole(roleId);
expect(result.deletedUserIds).toEqual(["user-1"]);
expect(await repo.findRoleById(roleId)).toBeNull();
expect(await repo.listUserRoleIds("user-1")).toEqual([]);
});
it("removes all roles for a user only when assignments exist", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const opsRoleId = await repo.createRole({
name: "ops",
displayName: "Operations",
isSystem: false,
permissions: null,
});
const auditRoleId = await repo.createRole({
name: "audit",
displayName: "Audit",
isSystem: false,
permissions: null,
});
await repo.assignRoleToUser({
userId: "user-1",
roleId: opsRoleId,
grantedBy: "admin",
});
await repo.assignRoleToUser({
userId: "user-1",
roleId: auditRoleId,
grantedBy: "admin",
});
expect(await repo.removeAllRolesFromUser("missing-user")).toBe(0);
expect(writeCount).toBe(4);
expect(await repo.removeAllRolesFromUser("user-1")).toBe(2);
expect(await repo.listUserRoleIds("user-1")).toEqual([]);
expect(writeCount).toBe(5);
});
it("runs the write hook after writes", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const roleId = await repo.createRole({
name: "ops",
displayName: "Operations",
isSystem: false,
permissions: null,
});
await repo.assignRoleToUser({
userId: "user-1",
roleId,
grantedBy: "admin",
});
await repo.updateRole(roleId, { displayName: "Ops" });
await repo.removeRoleFromUser("user-1", roleId);
await repo.deleteRole(roleId);
expect(writeCount).toBe(5);
});
});
@@ -0,0 +1,169 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SessionRecordingRepository } from "../../../database/repositories/session-recording-repository.js";
describe("SessionRecordingRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
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');
`);
return new SessionRecordingRepository(context, onWrite);
}
it("creates and lists session recordings with host metadata", async () => {
const repo = await createRepository();
const first = await repo.create({
userId: "user-1",
hostId: 1,
startedAt: "2026-06-27T00:00:00.000Z",
endedAt: "2026-06-27T00:01:00.000Z",
duration: 60,
recordingPath: "/tmp/one.log",
});
await repo.create({
userId: "user-1",
hostId: 2,
startedAt: "2026-06-27T00:02:00.000Z",
recordingPath: "/tmp/two.log",
});
await repo.create({
userId: "user-2",
hostId: 3,
startedAt: "2026-06-27T00:03:00.000Z",
});
expect(first).toMatchObject({
userId: "user-1",
hostId: 1,
duration: 60,
recordingPath: "/tmp/one.log",
});
const rows = await repo.listByUserIdWithHost("user-1");
expect(rows.map((row) => row.hostName)).toEqual(["two", "one"]);
expect(rows[0]).toMatchObject({
hostIp: "10.0.0.2",
recordingPath: "/tmp/two.log",
protocol: "ssh",
format: "text",
});
});
it("finds paths and prunes old recordings", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const old = await repo.create({
userId: "user-1",
hostId: 1,
startedAt: "2026-01-01T00:00:00.000Z",
recordingPath: "/tmp/old.log",
});
const current = await repo.create({
userId: "user-1",
hostId: 1,
startedAt: "2026-06-27T00:00:00.000Z",
recordingPath: "/tmp/current.log",
});
expect(writeCount).toBe(2);
expect(await repo.findPathByIdForUser("user-2", old.id)).toBeNull();
expect(await repo.findPathByIdForUser("user-1", old.id)).toMatchObject({
recordingPath: "/tmp/old.log",
});
expect(await repo.listPathsOlderThan("2026-02-01T00:00:00.000Z")).toEqual([
{ id: old.id, recordingPath: "/tmp/old.log" },
]);
expect(await repo.deleteById(old.id)).toBe(true);
expect(await repo.deleteById(old.id)).toBe(false);
expect(await repo.findByIdForUser("user-1", current.id)).toMatchObject({
id: current.id,
});
expect(writeCount).toBe(3);
});
it("deletes recordings by user and host references", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const first = await repo.create({
userId: "user-1",
hostId: 1,
startedAt: "2026-06-27T00:00:00.000Z",
});
await repo.create({
userId: "user-1",
hostId: 2,
startedAt: "2026-06-27T00:01:00.000Z",
});
await repo.create({
userId: "user-2",
hostId: 3,
startedAt: "2026-06-27T00:02:00.000Z",
});
expect(writeCount).toBe(3);
expect(await repo.deleteForUser("user-2", first.id)).toBe(false);
expect(await repo.deleteForUser("user-1", first.id)).toBe(true);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2])).toBe(1);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserId("user-2")).toBe(1);
expect(await repo.deleteByHostId(3)).toBe(0);
expect(writeCount).toBe(6);
});
});
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SettingsRepository } from "../../../database/repositories/settings-repository.js";
describe("SettingsRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
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);
}
it("creates and updates settings", async () => {
const repo = await createRepository();
await repo.set("allow_registration", "true");
expect(await repo.get("allow_registration")).toBe("true");
await repo.set("allow_registration", "false");
expect(await repo.get("allow_registration")).toBe("false");
});
it("lists and upserts settings", async () => {
const repo = await createRepository();
await repo.upsert("theme", "dark");
await repo.upsert("allow_registration", "true");
await repo.upsert("theme", "light");
expect(await repo.get("theme")).toBe("light");
expect(await repo.listAll()).toEqual(
expect.arrayContaining([
{ key: "theme", value: "light" },
{ key: "allow_registration", value: "true" },
]),
);
});
it("returns null or fallback when setting is missing", async () => {
const repo = await createRepository();
expect(await repo.get("missing")).toBeNull();
expect(await repo.getBoolean("missing", true)).toBe(true);
});
it("reads boolean settings", async () => {
const repo = await createRepository();
await repo.set("enabled", "1");
await repo.set("disabled", "false");
expect(await repo.getBoolean("enabled")).toBe(true);
expect(await repo.getBoolean("disabled", true)).toBe(false);
});
it("deletes settings", async () => {
const repo = await createRepository();
await repo.set("theme", "dark");
await repo.delete("theme");
expect(await repo.get("theme")).toBeNull();
});
it("deletes settings by SQL LIKE pattern", async () => {
const repo = await createRepository();
await repo.set("user_kek_salt_user-1", "salt");
await repo.set("user_encrypted_dek_user-1", "dek");
await repo.set("user_kek_salt_user-2", "other");
expect(await repo.deleteLike("user_%_user-1")).toBe(2);
expect(await repo.get("user_kek_salt_user-1")).toBeNull();
expect(await repo.get("user_encrypted_dek_user-1")).toBeNull();
expect(await repo.get("user_kek_salt_user-2")).toBe("other");
});
});
@@ -0,0 +1,231 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SharedHostSecretsRepository } from "../../../database/repositories/shared-host-secrets-repository.js";
describe("SharedHostSecretsRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
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);
INSERT INTO host_access (id, host_id, user_id, role_id, granted_by)
VALUES
(1, 42, 'user-1', NULL, 'owner-1'),
(2, 42, NULL, 7, 'owner-1'),
(3, 43, 'user-2', NULL, 'owner-1');
`);
return {
repository: new SharedHostSecretsRepository(context, onWrite),
sqlite: context.sqlite!,
};
}
it("upserts snapshots per protocol and finds them by host/user/protocol", async () => {
let writeCount = 0;
const { repository } = await createRepository(() => {
writeCount += 1;
});
await repository.upsert({
hostAccessId: 1,
targetUserId: "user-1",
protocol: "ssh",
sourceType: "credential",
originalCredentialId: 123,
encryptedUsername: "enc-user",
encryptedAuthType: "password",
encryptedPassword: "enc-pass",
});
await expect(
repository.findForHostUserProtocol(42, "user-1", "ssh"),
).resolves.toMatchObject({
hostAccessId: 1,
protocol: "ssh",
encryptedPassword: "enc-pass",
});
// Upsert on the same (grant, user, protocol) updates in place.
await repository.upsert({
hostAccessId: 1,
targetUserId: "user-1",
protocol: "ssh",
sourceType: "inline",
originalCredentialId: null,
encryptedUsername: "enc-user-2",
encryptedAuthType: "key",
encryptedKey: "enc-key",
});
const updated = await repository.findForHostUserProtocol(
42,
"user-1",
"ssh",
);
expect(updated).toMatchObject({
sourceType: "inline",
encryptedUsername: "enc-user-2",
encryptedKey: "enc-key",
});
await expect(
repository.findForHostUserProtocol(42, "user-1", "rdp"),
).resolves.toBeNull();
await expect(
repository.findForHostUserProtocol(43, "user-1", "ssh"),
).resolves.toBeNull();
expect(writeCount).toBe(2);
});
it("deletes stale protocols while keeping the listed ones", async () => {
const { repository } = await createRepository();
for (const protocol of ["ssh", "rdp", "vnc"] as const) {
await repository.upsert({
hostAccessId: 1,
targetUserId: "user-1",
protocol,
sourceType: "inline",
encryptedAuthType: "direct",
});
}
await repository.deleteForHostAccessAndTarget(1, "user-1", ["ssh"]);
await expect(
repository.findForHostUserProtocol(42, "user-1", "ssh"),
).resolves.not.toBeNull();
await expect(
repository.findForHostUserProtocol(42, "user-1", "rdp"),
).resolves.toBeNull();
await expect(
repository.findForHostUserProtocol(42, "user-1", "vnc"),
).resolves.toBeNull();
});
it("deletes by host access, target user, credential and role membership", async () => {
const { repository } = await createRepository();
await repository.upsert({
hostAccessId: 1,
targetUserId: "user-1",
protocol: "ssh",
sourceType: "credential",
originalCredentialId: 123,
encryptedAuthType: "password",
});
await repository.upsert({
hostAccessId: 2,
targetUserId: "user-1",
protocol: "ssh",
sourceType: "credential",
originalCredentialId: 123,
encryptedAuthType: "password",
});
await repository.upsert({
hostAccessId: 3,
targetUserId: "user-2",
protocol: "ssh",
sourceType: "inline",
encryptedAuthType: "password",
});
// Role-membership cleanup: only grant 2 targets role 7.
expect(await repository.deleteForRoleMember(7, "user-1")).toBe(1);
await expect(
repository.existsForHostAccessAndTargetUser(2, "user-1"),
).resolves.toBe(false);
await expect(
repository.existsForHostAccessAndTargetUser(1, "user-1"),
).resolves.toBe(true);
expect(await repository.deleteByHostAccessId(1)).toBe(1);
expect(await repository.deleteByTargetUserId("user-2")).toBe(1);
expect(await repository.deleteByOriginalCredentialId(123)).toBe(0);
});
it("finds host ids referencing a credential for the owner", async () => {
const { repository } = await createRepository();
await expect(
repository.findHostIdsReferencingCredential("owner-1", 123),
).resolves.toEqual([42]);
await expect(
repository.findHostIdsReferencingCredential("owner-1", 124),
).resolves.toEqual([42]);
await expect(
repository.findHostIdsReferencingCredential("owner-1", 999),
).resolves.toEqual([]);
await expect(
repository.findHostIdsReferencingCredential("owner-2", 123),
).resolves.toEqual([44]);
});
});
@@ -0,0 +1,379 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SnippetRepository } from "../../../database/repositories/snippet-repository.js";
describe("SnippetRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
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,
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,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO snippets (
id, user_id, name, content, description, folder, "order", host_filter
)
VALUES
(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'),
(2, 'user-1', 'db', NULL, NULL),
(3, 'user-2', 'other', NULL, NULL);
`);
return {
repository: new SnippetRepository(context, onWrite),
sqlite: context.sqlite!,
};
}
it("finds owned snippets only", async () => {
const { repository } = await createRepository();
await expect(repository.findOwnedById("user-1", 1)).resolves.toMatchObject({
id: 1,
userId: "user-1",
name: "root",
});
await expect(repository.findOwnedById("user-1", 3)).resolves.toBeNull();
await expect(repository.findOwnedById("user-1", 999)).resolves.toBeNull();
});
it("lists folders by name for a user", async () => {
const { repository } = await createRepository();
const rows = await repository.listFolders("user-1");
expect(rows.map((row) => row.name)).toEqual(["db", "ops"]);
});
it("lists export data for a user", async () => {
const { repository } = await createRepository();
const snippets = await repository.listSnippetsForExport("user-1");
const folders = await repository.listFoldersForExport("user-1");
expect(snippets.map((row) => row.name)).toEqual(["root", "deploy"]);
expect(folders.map((row) => row.name)).toEqual(["db", "ops"]);
});
it("lists owned snippets for route merging", async () => {
const { repository } = await createRepository();
const rows = await repository.listOwnedSnippets("user-1");
expect(rows.map((row) => row.name)).toEqual(["root", "deploy"]);
});
it("reorders snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
await repository.reorderSnippets("user-1", [
{ id: 1, order: 9, folder: " ops " },
{ id: 999, order: 1 },
]);
await expect(repository.findOwnedById("user-1", 1)).resolves.toMatchObject({
order: 9,
folder: "ops",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("creates snippets with the next folder order", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const created = await repository.createSnippet("user-1", {
name: " new ",
content: " echo ok ",
description: " desc ",
folder: "ops",
hostFilter: { os: "linux" },
});
expect(created).toMatchObject({
userId: "user-1",
name: "new",
content: "echo ok",
description: "desc",
folder: "ops",
order: 2,
hostFilter: JSON.stringify({ os: "linux" }),
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("updates snippets and returns the original row for audit names", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const result = await repository.updateSnippet("user-1", 2, {
name: " deploy new ",
content: " make deploy2 ",
description: null,
folder: null,
order: 7,
hostFilter: null,
});
const missing = await repository.updateSnippet("user-1", 3, {
name: "nope",
});
expect(result?.existing).toMatchObject({ name: "deploy" });
expect(result?.updated).toMatchObject({
name: "deploy new",
content: "make deploy2",
description: null,
folder: null,
order: 7,
hostFilter: null,
});
expect(missing).toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes snippets and returns the deleted row for audit names", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const deleted = await repository.deleteSnippet("user-1", 2);
const missing = await repository.deleteSnippet("user-1", 3);
expect(deleted).toMatchObject({ id: 2, name: "deploy" });
expect(missing).toBeNull();
await expect(repository.findOwnedById("user-1", 2)).resolves.toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes all snippets and folders for a user", async () => {
const onWrite = vi.fn();
const { repository, sqlite } = 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(),
).toEqual([{ id: 3 }]);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("bulk imports folders and snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const result = await repository.bulkImport(
"user-1",
[
{
name: " new snippet ",
content: " echo hi ",
description: " desc ",
folder: " new folder ",
hostFilter: "linux",
},
{ name: "", content: "bad" },
{ name: "deploy", content: "skip", folder: "ops" },
],
[
{ name: " new folder ", color: " #fff ", icon: " star " },
{ name: "ops" },
{ name: "" },
],
false,
);
expect(result).toEqual({
snippetsImported: 1,
snippetsSkipped: 1,
snippetsUpdated: 0,
foldersImported: 1,
foldersSkipped: 1,
failed: 2,
errors: [
"Folder missing name",
"Snippet 2: name and content are required",
],
});
await expect(repository.findOwnedById("user-1", 4)).resolves.toMatchObject({
name: "new snippet",
content: "echo hi",
description: "desc",
folder: "new folder",
order: 0,
hostFilter: "linux",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("bulk import overwrites existing snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const result = await repository.bulkImport(
"user-1",
[
{
name: "deploy",
content: "make deploy v2",
folder: "ops",
order: 5,
hostFilter: "prod",
},
],
undefined,
true,
);
expect(result).toMatchObject({
snippetsImported: 0,
snippetsSkipped: 0,
snippetsUpdated: 1,
failed: 0,
});
await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({
content: "make deploy v2",
order: 5,
hostFilter: "prod",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("creates folders and rejects duplicate names", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const created = await repository.createFolder(
"user-1",
" new ",
" #fff ",
" star ",
);
const duplicate = await repository.createFolder(
"user-1",
"ops",
null,
null,
);
expect(created).toMatchObject({
userId: "user-1",
name: "new",
color: "#fff",
icon: "star",
});
expect(duplicate).toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("updates folder metadata", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const updated = await repository.updateFolderMetadata(
"user-1",
"ops",
" #abc ",
undefined,
);
const missing = await repository.updateFolderMetadata(
"user-1",
"missing",
null,
null,
);
expect(updated).toMatchObject({
name: "ops",
color: "#abc",
icon: "terminal",
});
expect(missing).toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("renames folders and attached snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
await expect(
repository.renameFolder("user-1", "missing", "new"),
).resolves.toEqual({ status: "missing" });
await expect(
repository.renameFolder("user-1", "ops", "db"),
).resolves.toEqual({
status: "conflict",
});
await expect(
repository.renameFolder("user-1", "ops", "deploys"),
).resolves.toEqual({ status: "renamed" });
await expect(repository.listFolders("user-1")).resolves.toEqual(
expect.arrayContaining([expect.objectContaining({ name: "deploys" })]),
);
await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({
folder: "deploys",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes folders and moves snippets to the root", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
await repository.deleteFolder("user-1", "ops");
expect(
(await repository.listFolders("user-1")).map((row) => row.name),
).toEqual(["db"]);
await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({
folder: null,
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SshCredentialUsageRepository } from "../../../database/repositories/ssh-credential-usage-repository.js";
describe("SshCredentialUsageRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
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');
`);
return new SshCredentialUsageRepository(context, onWrite);
}
it("creates usage records", async () => {
const repo = await createRepository();
const created = await repo.create(1, 1, "user-1");
expect(created).toMatchObject({
credentialId: 1,
hostId: 1,
userId: "user-1",
});
});
it("lists usage records by user", async () => {
const repo = await createRepository();
await repo.create(1, 1, "user-1");
await repo.create(1, 2, "user-1");
await repo.create(2, 3, "user-2");
expect(
(await repo.listByUserId("user-1")).map((row) => row.hostId),
).toEqual([1, 2]);
});
it("deletes usage records by user, host, and host list", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create(1, 1, "user-1");
await repo.create(1, 2, "user-1");
await repo.create(2, 3, "user-2");
expect(writeCount).toBe(3);
expect(await repo.deleteByHostId(1)).toBe(1);
expect(await repo.deleteByHostId(1)).toBe(0);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2])).toBe(1);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserId("user-2")).toBe(1);
expect(await repo.deleteByUserId("user-2")).toBe(0);
expect(writeCount).toBe(6);
});
});
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SsoProviderRepository } from "../../../database/repositories/sso-provider-repository.js";
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;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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);
}
it("creates, lists, finds, updates, and deletes providers", async () => {
const repo = await createRepository();
const disabled = await repo.create({
name: "Disabled",
type: "oidc",
enabled: false,
displayOrder: 1,
config: "{}",
});
const enabled = await repo.create({
name: "GitHub",
type: "github",
enabled: true,
displayOrder: 0,
config: '{"client_id":"id"}',
});
expect((await repo.listEnabledPublic()).map((row) => row.id)).toEqual([
enabled.id,
]);
expect((await repo.listAll()).map((row) => row.id)).toEqual([
enabled.id,
disabled.id,
]);
expect((await repo.findById(enabled.id))?.name).toBe("GitHub");
expect((await repo.findFirstEnabledOidcLike())?.id).toBe(enabled.id);
const updated = await repo.update(enabled.id, {
name: "GitHub SSO",
config: '{"client_id":"updated"}',
updatedAt: "2026-06-27T00:00:00.000Z",
});
expect(updated?.name).toBe("GitHub SSO");
expect(updated?.config).toContain("updated");
expect(await repo.delete(enabled.id)).toBe(true);
expect(await repo.findById(enabled.id)).toBeNull();
expect(await repo.delete(enabled.id)).toBe(false);
});
it("counts users associated with a provider", async () => {
const repo = await createRepository();
const provider = await repo.create({
name: "LDAP",
type: "ldap",
enabled: true,
displayOrder: 0,
config: "{}",
});
sqlite?.exec(`
INSERT INTO users (id, username, password_hash, sso_provider_id)
VALUES ('user-1', 'u1', 'hash', ${provider.id}),
('user-2', 'u2', 'hash', ${provider.id}),
('user-3', 'u3', 'hash', NULL);
`);
expect(await repo.countUsersByProviderId(provider.id)).toBe(2);
});
it("runs the write hook after provider writes", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const provider = await repo.create({
name: "OIDC",
type: "oidc",
enabled: true,
displayOrder: 0,
config: "{}",
});
await repo.update(provider.id, { enabled: false });
await repo.delete(provider.id);
await repo.delete(provider.id);
expect(writeCount).toBe(3);
});
});
@@ -0,0 +1,274 @@
import { afterEach, describe, expect, it, 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";
describe("TermixIdentityCaRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
vi.restoreAllMocks();
if (adapter) {
await adapter.close();
adapter = null;
}
});
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
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash');
INSERT INTO termix_identities (id, user_id, handle)
VALUES (7, 'user-1', 'alice');
`);
return {
repo: new TermixIdentityCaRepository(context, onWrite),
sqlite: context.sqlite!,
onWrite,
};
}
function mockCrypto(): void {
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,
privateKey: "encrypted-ca-private",
}) as typeof record,
);
vi.spyOn(DataCrypto, "decryptRecord").mockImplementation(
(_tableName, record) =>
({
...record,
privateKey:
record.privateKey === "encrypted-ca-private"
? "decrypted-ca-private"
: record.privateKey,
}) as typeof record,
);
}
it("creates CA private keys with the real row id before encryption", async () => {
const { repo, sqlite, onWrite } = await createRepository();
mockCrypto();
const created = await repo.createEncryptedForUser("user-1", {
identityId: 7,
userId: "user-1",
publicKey: "ssh-ed25519 public",
privateKey: "plain-ca-private",
validityDays: 120,
});
const raw = sqlite
.prepare(
"SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?",
)
.get(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");
expect(raw.public_key).toBe("ssh-ed25519 public");
expect(raw.validity_days).toBe(120);
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
{ id: raw.id, privateKey: "plain-ca-private" },
"user-1",
Buffer.from("user-key"),
);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("reads public CA metadata without decrypting private key material", async () => {
const { repo, sqlite } = 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 expect(repo.findPublicByIdentityId(7)).resolves.toEqual({
publicKey: "ssh-ed25519 public",
validityDays: 45,
});
expect(decryptSpy).not.toHaveBeenCalled();
});
it("decrypts CA private keys through the user data boundary", async () => {
const { repo, sqlite } = 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);
const ca = await repo.findDecryptedByIdentityId("user-1", 7);
expect(ca).toMatchObject({
identityId: 7,
publicKey: "ssh-ed25519 public",
privateKey: "decrypted-ca-private",
validityDays: 45,
});
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
expect.objectContaining({ identityId: 7 }),
"user-1",
Buffer.from("user-key"),
);
});
it("updates CA private keys through encrypted writes", async () => {
const { repo, sqlite, 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);
onWrite.mockClear();
const updated = await repo.updateEncryptedForIdentity("user-1", 7, {
publicKey: "ssh-ed25519 new",
privateKey: "plain-updated-ca-private",
validityDays: 90,
});
const raw = sqlite
.prepare(
"SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?",
)
.get(7) as {
public_key: string;
private_key: string;
validity_days: number;
};
expect(updated).toMatchObject({
publicKey: "ssh-ed25519 new",
privateKey: "decrypted-ca-private",
validityDays: 90,
});
expect(raw).toEqual({
public_key: "ssh-ed25519 new",
private_key: "encrypted-ca-private",
validity_days: 90,
});
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"termix_identity_ca",
expect.objectContaining({
privateKey: "plain-updated-ca-private",
}),
"user-1",
Buffer.from("user-key"),
);
expect(onWrite).toHaveBeenCalledTimes(1);
});
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);
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 });
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);
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(),
).toEqual([{ user_id: "user-2", public_key: "ssh-ed25519 other" }]);
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,202 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { TermixIdentityRepository } from "../../../database/repositories/termix-identity-repository.js";
describe("TermixIdentityRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite = vi.fn()): Promise<{
repo: TermixIdentityRepository;
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_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
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return {
repo: new TermixIdentityRepository(context, onWrite),
onWrite,
};
}
it("creates, updates, finds, and deletes identities", async () => {
const { repo, onWrite } = await createRepository();
const created = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: "workstation keys",
});
expect(created.id).toBeGreaterThan(0);
expect(await repo.isHandleTaken("alice")).toBe(true);
expect(await repo.findIdentityForUser("user-1")).toMatchObject({
handle: "alice",
});
expect(await repo.findIdentityByHandle("alice")).toMatchObject({
userId: "user-1",
});
const updated = await repo.updateIdentityForUser("user-1", {
handle: "alice-renamed",
description: null,
});
expect(updated).toMatchObject({
handle: "alice-renamed",
description: null,
});
await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(true);
await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(false);
await expect(repo.findIdentityForUser("user-1")).resolves.toBeNull();
expect(onWrite).toHaveBeenCalledTimes(3);
});
it("creates, lists, updates, deletes, and links public keys", async () => {
const { repo, onWrite } = await createRepository();
const identity = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: null,
});
onWrite.mockClear();
const first = await repo.createKey({
identityId: identity.id,
userId: "user-1",
publicKey: "ssh-ed25519 AAAA1",
keyType: "ssh-ed25519",
algorithm: "ED25519",
label: "laptop",
comment: null,
source: "credential",
credentialId: 10,
});
await repo.createKey({
identityId: identity.id,
userId: "user-1",
publicKey: "ssh-rsa AAAA2",
keyType: "ssh-rsa",
algorithm: "RSA",
label: "disabled",
comment: null,
source: "manual",
credentialId: 20,
enabled: false,
});
expect(await repo.listKeysByIdentityId(identity.id)).toHaveLength(2);
expect(await repo.listEnabledKeysByIdentityId(identity.id)).toMatchObject([
{ id: first.id, publicKey: "ssh-ed25519 AAAA1" },
]);
expect(await repo.listLinkedCredentialIds(identity.id)).toEqual([10]);
const updated = await repo.updateKeyForUser("user-1", first.id, {
enabled: false,
label: "revoked",
});
expect(updated).toMatchObject({ enabled: false, label: "revoked" });
await expect(
repo.findKeyForUser("user-1", first.id),
).resolves.toMatchObject({ label: "revoked" });
await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe(true);
await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe(
false,
);
expect(onWrite).toHaveBeenCalledTimes(4);
});
it("deletes identities and keys for a user", async () => {
const { repo, onWrite } = await createRepository();
const userIdentity = await repo.createIdentity({
userId: "user-1",
handle: "alice",
description: null,
});
const otherIdentity = await repo.createIdentity({
userId: "user-2",
handle: "bob",
description: null,
});
await repo.createKey({
identityId: userIdentity.id,
userId: "user-1",
publicKey: "ssh-ed25519 AAAA1",
keyType: "ssh-ed25519",
algorithm: "ED25519",
source: "manual",
});
await repo.createKey({
identityId: otherIdentity.id,
userId: "user-2",
publicKey: "ssh-ed25519 AAAA2",
keyType: "ssh-ed25519",
algorithm: "ED25519",
source: "manual",
});
onWrite.mockClear();
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
identitiesDeleted: 1,
keysDeleted: 1,
});
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
identitiesDeleted: 0,
keysDeleted: 0,
});
expect(await repo.findIdentityForUser("user-1")).toBeNull();
expect(await repo.listKeysByIdentityId(userIdentity.id)).toEqual([]);
expect(await repo.findIdentityForUser("user-2")).toMatchObject({
handle: "bob",
});
expect(await repo.listKeysByIdentityId(otherIdentity.id)).toHaveLength(1);
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,31 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "../../../database/db/schema.js";
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
export class TestSqliteDatabase {
private sqlite: Database.Database | null = null;
private context: DatabaseContext | null = null;
async connect(): Promise<DatabaseContext> {
if (this.context) return this.context;
this.sqlite = new Database(":memory:");
this.sqlite.exec("PRAGMA foreign_keys = ON");
this.context = {
dialect: "sqlite",
drizzle: drizzle(this.sqlite, { schema }),
sqlite: this.sqlite,
};
return this.context;
}
async close(): Promise<void> {
if (this.sqlite) {
this.sqlite.close();
this.sqlite = null;
this.context = null;
}
}
}
@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { TmuxSessionTagRepository } from "../../../database/repositories/tmux-session-tag-repository.js";
describe("TmuxSessionTagRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
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 tmux_session_tags (user_id, host_id, session_name, tag)
VALUES
('user-1', 1, 'api', 'prod'),
('user-1', 1, 'api', 'critical'),
('user-1', 1, 'worker', 'batch'),
('user-2', 2, 'api', 'other');
`);
return new TmuxSessionTagRepository(context, onWrite);
}
it("groups tags by session for a user and host", async () => {
const repo = await createRepository();
const tags = await repo.listByUserAndHost("user-1", 1);
expect(tags.get("api")).toEqual(["prod", "critical"]);
expect(tags.get("worker")).toEqual(["batch"]);
expect(tags.has("missing")).toBe(false);
});
it("renames and deletes session tags by host/session", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(await repo.renameSessionForHost(1, "api", "api-renamed")).toBe(2);
expect(await repo.renameSessionForHost(1, "missing", "noop")).toBe(0);
const renamed = await repo.listByUserAndHost("user-1", 1);
expect(renamed.get("api-renamed")).toEqual(["prod", "critical"]);
expect(renamed.has("api")).toBe(false);
expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(2);
expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(0);
expect(writeCount).toBe(2);
});
it("replaces tags for one user/host/session", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.replaceForUserHostSession("user-1", 1, "api", [
"blue",
"green",
]),
).toBe(4);
const tags = await repo.listByUserAndHost("user-1", 1);
expect(tags.get("api")).toEqual(["blue", "green"]);
expect(tags.get("worker")).toEqual(["batch"]);
expect(
await repo.replaceForUserHostSession("user-1", 1, "worker", []),
).toBe(1);
expect(
await repo.replaceForUserHostSession("user-1", 1, "missing", []),
).toBe(0);
expect(writeCount).toBe(2);
});
it("deletes all tags for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(repo.deleteByUserId("user-1")).resolves.toBe(3);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.listByUserAndHost("user-1", 1)).toEqual(new Map());
expect(await repo.listByUserAndHost("user-2", 2)).toEqual(
new Map([["api", ["other"]]]),
);
expect(writeCount).toBe(1);
});
});
@@ -0,0 +1,141 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { TransferRecentRepository } from "../../../database/repositories/transfer-recent-repository.js";
describe("TransferRecentRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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
);
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');
`);
return new TransferRecentRepository(context, onWrite);
}
it("upserts, lists, and prunes recent transfer destinations", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForDestination(
"user-1",
{ sourceHostId: 1, destHostId: 2, destPath: "/var/www" },
"2026-01-01T00:00:00.000Z",
);
await repo.upsertForDestination(
"user-1",
{
sourceHostId: 1,
destHostId: 3,
destPath: "/opt/app",
destPathLabel: "App",
},
"2026-01-02T00:00:00.000Z",
);
await repo.upsertForDestination(
"user-1",
{ sourceHostId: 1, destHostId: 2, destPath: "/var/www" },
"2026-01-03T00:00:00.000Z",
);
const recent = await repo.listBySourceHost("user-1", 1);
expect(recent).toHaveLength(2);
expect(recent.map((entry) => entry.destPath)).toEqual([
"/var/www",
"/opt/app",
]);
expect(recent[0].lastUsed).toBe("2026-01-03T00:00:00.000Z");
expect(recent[0].destPathLabel).toBe("/var/www");
expect(writeCount).toBe(3);
expect(await repo.pruneSourceHost("user-1", 1, 1)).toBe(1);
expect(await repo.pruneSourceHost("user-1", 1, 1)).toBe(0);
expect(await repo.listBySourceHost("user-1", 1)).toHaveLength(1);
expect(writeCount).toBe(4);
});
it("deletes recent transfer destinations by user and host references", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForDestination("user-1", {
sourceHostId: 1,
destHostId: 2,
destPath: "/one",
});
await repo.upsertForDestination("user-1", {
sourceHostId: 2,
destHostId: 3,
destPath: "/two",
});
await repo.upsertForDestination("user-2", {
sourceHostId: 4,
destHostId: 1,
destPath: "/other",
});
expect(writeCount).toBe(3);
expect(await repo.deleteByHostId(1)).toBe(2);
expect(await repo.deleteByHostId(1)).toBe(0);
expect(writeCount).toBe(4);
await repo.upsertForDestination("user-1", {
sourceHostId: 1,
destHostId: 2,
destPath: "/three",
});
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2, 3])).toBe(2);
expect(writeCount).toBe(6);
await repo.upsertForDestination("user-2", {
sourceHostId: 4,
destHostId: 1,
destPath: "/last",
});
expect(await repo.deleteByUserId("user-2")).toBe(1);
expect(await repo.deleteByUserId("user-2")).toBe(0);
expect(writeCount).toBe(8);
});
});
@@ -0,0 +1,164 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { TrustedDeviceRepository } from "../../../database/repositories/trusted-device-repository.js";
describe("TrustedDeviceRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite?: () => void): Promise<{
trustedDevices: 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
);
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'admin', 'hash'),
('user-2', 'user', 'hash');
`);
return {
trustedDevices: new TrustedDeviceRepository(context, onWrite),
};
}
it("upserts, touches, finds, and deletes trusted devices", async () => {
const repo = await createRepository();
await repo.trustedDevices.upsert({
id: "device-1",
userId: "user-1",
deviceFingerprint: "fingerprint",
deviceType: "desktop",
deviceInfo: "Firefox",
createdAt: "2026-06-26T00:00:00.000Z",
expiresAt: "2026-07-26T00:00:00.000Z",
lastUsedAt: "2026-06-26T00:00:00.000Z",
});
expect(
(
await repo.trustedDevices.findByUserAndFingerprint(
"user-1",
"fingerprint",
)
)?.deviceType,
).toBe("desktop");
await repo.trustedDevices.touch(
"user-1",
"fingerprint",
"2026-06-26T01:00:00.000Z",
);
expect(
(
await repo.trustedDevices.findByUserAndFingerprint(
"user-1",
"fingerprint",
)
)?.lastUsedAt,
).toBe("2026-06-26T01:00:00.000Z");
await repo.trustedDevices.upsert({
id: "device-ignored",
userId: "user-1",
deviceFingerprint: "fingerprint",
deviceType: "mobile",
deviceInfo: "Safari",
expiresAt: "2026-08-26T00:00:00.000Z",
lastUsedAt: "2026-06-26T02:00:00.000Z",
});
const updated = await repo.trustedDevices.findByUserAndFingerprint(
"user-1",
"fingerprint",
);
expect(updated?.id).toBe("device-1");
expect(updated?.deviceType).toBe("desktop");
expect(updated?.expiresAt).toBe("2026-08-26T00:00:00.000Z");
await repo.trustedDevices.deleteByUserAndFingerprint(
"user-1",
"fingerprint",
);
expect(
await repo.trustedDevices.findByUserAndFingerprint(
"user-1",
"fingerprint",
),
).toBeNull();
});
it("deletes all trusted devices for a user", async () => {
const repo = await createRepository();
for (const id of ["device-1", "device-2"]) {
await repo.trustedDevices.upsert({
id,
userId: "user-2",
deviceFingerprint: id,
deviceType: "desktop",
deviceInfo: "Firefox",
expiresAt: "2026-07-26T00:00:00.000Z",
});
}
await repo.trustedDevices.deleteByUserId("user-2");
expect(
await repo.trustedDevices.findByUserAndFingerprint("user-2", "device-1"),
).toBeNull();
expect(
await repo.trustedDevices.findByUserAndFingerprint("user-2", "device-2"),
).toBeNull();
});
it("runs the write hook after trusted device writes", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.trustedDevices.upsert({
id: "device-1",
userId: "user-1",
deviceFingerprint: "fingerprint",
deviceType: "desktop",
deviceInfo: "Firefox",
expiresAt: "2026-07-26T00:00:00.000Z",
});
await repo.trustedDevices.touch("user-1", "fingerprint");
await repo.trustedDevices.deleteByUserAndFingerprint(
"user-1",
"fingerprint",
);
expect(writeCount).toBe(3);
});
});
@@ -0,0 +1,180 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { UserDataExportRepository } from "../../../database/repositories/user-data-export-repository.js";
describe("UserDataExportRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
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,
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,
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,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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');
`);
return new UserDataExportRepository(context);
}
it("lists only the current user's exportable hosts and credentials", async () => {
const repository = await createRepository();
expect(await repository.listHostsByUserId("user-1")).toMatchObject([
{
id: 1,
userId: "user-1",
name: "web",
ip: "10.0.0.1",
},
]);
expect(await repository.listCredentialsByUserId("user-1")).toMatchObject([
{
id: 1,
userId: "user-1",
name: "prod",
username: "root",
},
]);
});
});
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { UserPreferenceRepository } from "../../../database/repositories/user-preference-repository.js";
describe("UserPreferenceRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash');
`);
return new UserPreferenceRepository(context, onWrite);
}
it("finds, creates, and updates preferences by user id", async () => {
const repo = await createRepository();
expect(await repo.findByUserId("user-1")).toBeNull();
const created = await repo.upsert("user-1", {
reopenTabsOnLogin: true,
theme: "dark",
storageMode: "local",
commandAutocomplete: true,
});
expect(created).toMatchObject({
userId: "user-1",
reopenTabsOnLogin: true,
theme: "dark",
storageMode: "local",
commandAutocomplete: true,
});
const updated = await repo.upsert("user-1", {
theme: "light",
commandAutocomplete: false,
updatedAt: "2026-06-27T00:00:00.000Z",
});
expect(updated).toMatchObject({
userId: "user-1",
reopenTabsOnLogin: true,
theme: "light",
storageMode: "local",
commandAutocomplete: false,
});
});
it("deletes preferences by user id only when rows exist", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsert("user-1", { theme: "dark" });
expect(writeCount).toBe(1);
expect(await repo.deleteByUserId("missing")).toBe(0);
expect(writeCount).toBe(1);
expect(await repo.deleteByUserId("user-1")).toBe(1);
expect(writeCount).toBe(2);
expect(await repo.findByUserId("user-1")).toBeNull();
});
});
@@ -0,0 +1,296 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SessionRepository } from "../../../database/repositories/session-repository.js";
import { UserRepository } from "../../../database/repositories/user-repository.js";
describe("UserRepository and SessionRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepositories(): Promise<{
users: UserRepository;
sessions: SessionRepository;
}>;
async function createRepositories(options: {
onUserWrite?: () => void | Promise<void>;
onSessionWrite?: () => void | Promise<void>;
}): Promise<{
users: UserRepository;
sessions: SessionRepository;
}>;
async function createRepositories(
options: {
onUserWrite?: () => void | Promise<void>;
onSessionWrite?: () => void | Promise<void>;
} = {},
): Promise<{
users: UserRepository;
sessions: 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),
sessions: new SessionRepository(context, options.onSessionWrite),
};
}
it("creates, finds, updates, and deletes users", async () => {
const repo = await createRepositories();
await repo.users.create({
id: "user-1",
username: "admin",
passwordHash: "hash",
isAdmin: true,
isOidc: false,
});
expect(await repo.users.countAdmins()).toBe(1);
expect(await repo.users.countTotpEnabled()).toBe(0);
expect((await repo.users.listAll()).map((user) => user.id)).toEqual([
"user-1",
]);
expect((await repo.users.findByUsername("admin"))?.id).toBe("user-1");
expect(
(await repo.users.listByIds(["user-1", "user-1"])).map((u) => u.id),
).toEqual(["user-1"]);
expect(await repo.users.listByIds([])).toEqual([]);
const updated = await repo.users.update("user-1", {
oidcIdentifier: "oidc:admin",
totpEnabled: true,
});
expect(updated?.oidcIdentifier).toBe("oidc:admin");
expect(await repo.users.countTotpEnabled()).toBe(1);
expect((await repo.users.findByOidcIdentifier("oidc:admin"))?.id).toBe(
"user-1",
);
expect(await repo.users.delete("user-1")).toBe(true);
expect(await repo.users.findById("user-1")).toBeNull();
});
it("creates the first local user as admin inside the repository", async () => {
const repo = await createRepositories();
const first = await repo.users.createFirstLocalUser({
id: "user-1",
username: "first",
passwordHash: "hash",
isOidc: false,
});
const second = await repo.users.createFirstLocalUser({
id: "user-2",
username: "second",
passwordHash: "hash",
isOidc: false,
});
expect(first.isFirstUser).toBe(true);
expect(first.user.isAdmin).toBe(true);
expect(second.isFirstUser).toBe(false);
expect(second.user.isAdmin).toBe(false);
expect(await repo.users.countAll()).toBe(2);
});
it("creates SSO users with first-user and provider admin semantics", async () => {
const repo = await createRepositories();
const first = await repo.users.createFirstSsoUser({
id: "user-1",
username: "first",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:provider:first",
ssoProviderId: 1,
});
const providerAdmin = await repo.users.createFirstSsoUser({
id: "user-2",
username: "provider-admin",
passwordHash: "",
isAdmin: true,
isOidc: true,
oidcIdentifier: "ldap:provider:admin",
ssoProviderId: 1,
});
const regular = await repo.users.createFirstSsoUser({
id: "user-3",
username: "regular",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:provider:regular",
ssoProviderId: 1,
});
expect(first.isFirstUser).toBe(true);
expect(first.user.isAdmin).toBe(true);
expect(providerAdmin.isFirstUser).toBe(false);
expect(providerAdmin.user.isAdmin).toBe(true);
expect(regular.isFirstUser).toBe(false);
expect(regular.user.isAdmin).toBe(false);
expect(await repo.users.countAll()).toBe(3);
});
it("runs the user write hook after user writes", async () => {
let writeCount = 0;
const repo = await createRepositories({
onUserWrite: () => {
writeCount += 1;
},
});
await repo.users.create({
id: "user-1",
username: "admin",
passwordHash: "hash",
isAdmin: true,
isOidc: false,
});
await repo.users.update("user-1", { isAdmin: false });
await repo.users.delete("user-1");
expect(writeCount).toBe(3);
});
it("creates, touches, lists, and revokes sessions", async () => {
const repo = await createRepositories();
await repo.users.create({
id: "user-1",
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
id: "session-1",
userId: "user-1",
jwtToken: "token",
deviceType: "desktop",
deviceInfo: "Firefox",
createdAt: "2026-06-26T00:00:00.000Z",
expiresAt: "2026-06-27T00:00:00.000Z",
lastActiveAt: "2026-06-26T00:00:00.000Z",
});
await repo.sessions.touch("session-1", "2026-06-26T01:00:00.000Z");
expect((await repo.sessions.findById("session-1"))?.lastActiveAt).toBe(
"2026-06-26T01:00:00.000Z",
);
expect(await repo.sessions.listByUserId("user-1")).toHaveLength(1);
expect(await repo.sessions.revoke("session-1")).toBe(true);
expect(await repo.sessions.findById("session-1")).toBeNull();
});
it("revokes all user sessions except an optional current session", async () => {
const repo = await createRepositories();
await repo.users.create({
id: "user-1",
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
for (const id of ["keep", "drop-1", "drop-2"]) {
await repo.sessions.create({
id,
userId: "user-1",
jwtToken: `${id}-token`,
deviceType: "desktop",
deviceInfo: "Firefox",
expiresAt: "2026-06-27T00:00:00.000Z",
});
}
expect(await repo.sessions.revokeAllForUser("user-1", "keep")).toBe(2);
expect(
(await repo.sessions.listByUserId("user-1")).map((s) => s.id),
).toEqual(["keep"]);
});
it("deletes expired sessions", async () => {
const repo = await createRepositories();
await repo.users.create({
id: "user-1",
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
id: "expired",
userId: "user-1",
jwtToken: "expired-token",
deviceType: "desktop",
deviceInfo: "Firefox",
expiresAt: "2026-06-25T00:00:00.000Z",
});
await repo.sessions.create({
id: "active",
userId: "user-1",
jwtToken: "active-token",
deviceType: "desktop",
deviceInfo: "Firefox",
expiresAt: "2026-06-27T00:00:00.000Z",
});
expect(
await repo.sessions.deleteExpired(new Date("2026-06-26T00:00:00.000Z")),
).toBe(1);
expect(
(await repo.sessions.listByUserId("user-1")).map((s) => s.id),
).toEqual(["active"]);
});
});
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { VaultProfileRepository } from "../../../database/repositories/vault-profile-repository.js";
describe("VaultProfileRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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'),
(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');
`);
return new VaultProfileRepository(context, onWrite);
}
it("lists profiles owned by or shared with the user", async () => {
const repo = await createRepository();
const rows = await repo.listVisibleToUser("user-1");
expect(rows.map((row) => row.id)).toEqual([2, 1]);
});
it("creates and reads a profile", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const created = await repo.create({
userId: "user-1",
name: "new",
description: "desc",
folder: "folder",
tags: "prod,ssh",
vaultAddr: "https://vault.new",
vaultNamespace: "ns",
oidcMount: "oidc",
oidcRole: "oidc-role",
sshMount: "ssh",
sshRole: "ssh-role",
validPrincipals: "root",
keyType: "ed25519",
shared: true,
});
const found = await repo.findById(created.id);
expect(found).toMatchObject({
userId: "user-1",
name: "new",
vaultAddr: "https://vault.new",
sshRole: "ssh-role",
shared: true,
});
expect(writeCount).toBe(1);
});
it("updates and deletes profiles", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const updated = await repo.updateById(1, {
name: "renamed",
shared: true,
updatedAt: "2026-02-01T00:00:00.000Z",
});
expect(updated).toMatchObject({
id: 1,
name: "renamed",
shared: true,
updatedAt: "2026-02-01T00:00:00.000Z",
});
expect(await repo.updateById(999, { name: "missing" })).toBeNull();
expect(await repo.deleteById(1)).toBe(true);
expect(await repo.deleteById(1)).toBe(false);
expect(await repo.findById(1)).toBeNull();
expect(writeCount).toBe(2);
});
it("deletes all profiles for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(repo.deleteByUserId("user-2")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.findById(2)).toBeNull();
expect(await repo.findById(3)).toBeNull();
expect((await repo.findById(1))?.userId).toBe("user-1");
expect(writeCount).toBe(1);
});
});
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { VaultTokenRepository } from "../../../database/repositories/vault-token-repository.js";
describe("VaultTokenRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): 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)
);
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_tokens (
user_id, profile_id, ssh_cert, private_key, expires_at
)
VALUES
('user-1', 1, 'cert-1', 'key-1', '2099-01-01T00:00:00.000Z'),
('user-2', 2, 'cert-2', 'key-2', '2099-01-01T00:00:00.000Z');
`);
return new VaultTokenRepository(context, onWrite);
}
it("finds and upserts a token by user and profile", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const existing = await repo.findByUserAndProfile("user-1", 1);
expect(existing?.sshCert).toBe("cert-1");
await repo.upsert({
userId: "user-1",
profileId: 1,
sshCert: "new-cert",
privateKey: "new-key",
expiresAt: "2099-02-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
});
const updated = await repo.findByUserAndProfile("user-1", 1);
expect(updated).toMatchObject({
sshCert: "new-cert",
privateKey: "new-key",
expiresAt: "2099-02-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
});
expect(writeCount).toBe(1);
});
it("updates last-used and deletes one token", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"),
).toBe(true);
expect(await repo.updateLastUsed("missing", 1)).toBe(false);
expect((await repo.findByUserAndProfile("user-1", 1))?.lastUsed).toBe(
"2026-01-02T00:00:00.000Z",
);
expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(true);
expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(false);
expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull();
expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull();
expect(writeCount).toBe(2);
});
it("deletes all tokens for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsert({
userId: "user-1",
profileId: 2,
sshCert: "cert-extra",
privateKey: "key-extra",
expiresAt: "2099-03-01T00:00:00.000Z",
});
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull();
expect(await repo.findByUserAndProfile("user-1", 2)).toBeNull();
expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull();
expect(writeCount).toBe(2);
});
});
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { shouldShowDonationModal } from "../../../database/routes/donation-modal-utils.js";
describe("shouldShowDonationModal", () => {
const now = Date.parse("2026-07-17T00:00:00.000Z");
it("returns false when the user already dismissed it", () => {
const registeredAt = new Date(now - 60 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, true, now)).toBe(false);
});
it("returns false before the 30 day mark", () => {
const registeredAt = new Date(now - 29 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(false);
});
it("returns true at exactly 30 days", () => {
const registeredAt = new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
});
it("returns true well past the 30 day mark", () => {
const registeredAt = new Date(
now - 200 * 24 * 60 * 60 * 1000,
).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
});
it("returns false for an unparseable registeredAt", () => {
expect(shouldShowDonationModal("not-a-date", false, now)).toBe(false);
});
it("treats a backdated registeredAt for pre-existing users as immediately eligible", () => {
const registeredAt = new Date(now - 31 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
});
});
@@ -0,0 +1,104 @@
import { describe, it, expect } from "vitest";
import { parseSSHConfig } from "../../../database/routes/host-bulk-routes.js";
describe("parseSSHConfig", () => {
it("parses a basic Host block", () => {
const config = `
Host myserver
HostName 192.168.1.10
User alice
Port 2222
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
name: "myserver",
hostname: "192.168.1.10",
user: "alice",
port: 2222,
});
});
it("parses multiple Host blocks", () => {
const config = `
Host web
HostName web.example.com
User deploy
Host db
HostName db.example.com
User postgres
Port 5432
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(2);
expect(result[0].name).toBe("web");
expect(result[1].name).toBe("db");
expect(result[1].port).toBe(5432);
});
it("ignores comment lines", () => {
const config = `
# This is a comment
Host server
# Another comment
HostName 10.0.0.1
User root
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0].hostname).toBe("10.0.0.1");
});
it("skips wildcard Host entries", () => {
const config = `
Host *
ServerAliveInterval 60
Host prod
HostName prod.example.com
User ubuntu
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("prod");
});
it("captures IdentityFile and ProxyJump", () => {
const config = `
Host bastion
HostName bastion.example.com
User ec2-user
IdentityFile ~/.ssh/id_rsa
ProxyJump jumphost.example.com
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(1);
expect(result[0].identityFile).toBe("~/.ssh/id_rsa");
expect(result[0].proxyJump).toBe("jumphost.example.com");
});
it("skips Host blocks without a HostName", () => {
const config = `
Host alias-only
User foo
`;
const result = parseSSHConfig(config);
expect(result).toHaveLength(0);
});
it("defaults port to undefined when not specified", () => {
const config = `
Host server
HostName 1.2.3.4
User root
`;
const result = parseSSHConfig(config);
expect(result[0].port).toBeUndefined();
});
it("returns empty array for empty input", () => {
expect(parseSSHConfig("")).toHaveLength(0);
expect(parseSSHConfig(" \n\n ")).toHaveLength(0);
});
});
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
isUserDataUnlocked: vi.fn(),
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: {
canUserAccessData: mocks.isUserDataUnlocked,
},
}));
const { applyHostEnrollmentDefaults, requireHostEnrollmentAccessForPath } =
await import("../../../database/routes/host-enrollment-auth.js");
function response() {
const json = vi.fn();
const status = vi.fn(() => ({ json }));
return { status, json };
}
describe("requireHostEnrollmentAccessForPath", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isUserDataUnlocked.mockReturnValue(true);
});
it("accepts an API key scoped to an unlocked user", () => {
const res = response();
const next = vi.fn();
requireHostEnrollmentAccessForPath(
{ path: "/enroll", userId: "user-1", apiKeyId: "key-1" } as never,
res as never,
next,
);
expect(mocks.isUserDataUnlocked).toHaveBeenCalledWith("user-1");
expect(next).toHaveBeenCalledOnce();
});
it("rejects regular JWT sessions", () => {
const res = response();
const next = vi.fn();
requireHostEnrollmentAccessForPath(
{ path: "/enroll", userId: "user-1" } as never,
res as never,
next,
);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: "Host enrollment requires an API key",
code: "API_KEY_REQUIRED",
});
expect(next).not.toHaveBeenCalled();
});
it("reports locked encrypted data explicitly", () => {
mocks.isUserDataUnlocked.mockReturnValue(false);
const res = response();
const next = vi.fn();
requireHostEnrollmentAccessForPath(
{ path: "/enroll", userId: "user-1", apiKeyId: "key-1" } as never,
res as never,
next,
);
expect(res.status).toHaveBeenCalledWith(423);
expect(res.json).toHaveBeenCalledWith({
error: "User data is locked. Sign in before enrolling hosts.",
code: "DATA_LOCKED",
});
expect(next).not.toHaveBeenCalled();
});
it("leaves the existing host route unchanged", () => {
const res = response();
const next = vi.fn();
requireHostEnrollmentAccessForPath(
{ path: "/db/host", userId: "user-1" } as never,
res as never,
next,
);
expect(next).toHaveBeenCalledOnce();
expect(mocks.isUserDataUnlocked).not.toHaveBeenCalled();
});
});
describe("applyHostEnrollmentDefaults", () => {
it("creates a usable SSH host from a minimal enrollment payload", () => {
expect(applyHostEnrollmentDefaults({ ip: "server.example" })).toEqual({
connectionType: "ssh",
ip: "server.example",
port: 22,
authType: "none",
enableTerminal: true,
enableSsh: true,
});
});
it("preserves explicit enrollment settings", () => {
expect(
applyHostEnrollmentDefaults({
ip: "server.example",
port: 2222,
authType: "password",
enableTerminal: false,
}),
).toMatchObject({
port: 2222,
authType: "password",
enableTerminal: false,
});
});
});
@@ -0,0 +1,277 @@
import { describe, it, expect } from "vitest";
import {
isNonEmptyString,
isValidPort,
normalizeImportedHost,
renameFolderPath,
sanitizeHostForRecipient,
stripSensitiveFields,
transformHostResponse,
} from "../../../database/routes/host-normalizers.js";
describe("isNonEmptyString", () => {
it("accepts non-blank strings", () => {
expect(isNonEmptyString("hello")).toBe(true);
expect(isNonEmptyString(" x ")).toBe(true);
});
it("rejects blank strings and non-strings", () => {
expect(isNonEmptyString("")).toBe(false);
expect(isNonEmptyString(" ")).toBe(false);
expect(isNonEmptyString(123)).toBe(false);
expect(isNonEmptyString(null)).toBe(false);
expect(isNonEmptyString(undefined)).toBe(false);
});
});
describe("renameFolderPath", () => {
it("renames an exact folder match", () => {
expect(renameFolderPath("Production", "Production", "Prod")).toBe("Prod");
});
it("re-paths nested children under the renamed ancestor", () => {
expect(renameFolderPath("Production / Web", "Production", "Prod")).toBe(
"Prod / Web",
);
expect(
renameFolderPath("Production / Web / app01", "Production", "Prod"),
).toBe("Prod / Web / app01");
});
it("renames a nested folder itself and keeps its parent", () => {
expect(
renameFolderPath("Production / Web", "Production / Web", "Frontend"),
).toBe("Frontend");
expect(
renameFolderPath(
"Production / Web / app01",
"Production / Web",
"Production / Frontend",
),
).toBe("Production / Frontend / app01");
});
it("returns null for unrelated folders", () => {
expect(renameFolderPath("Staging", "Production", "Prod")).toBeNull();
expect(renameFolderPath("Production2", "Production", "Prod")).toBeNull();
expect(
renameFolderPath("ProductionExtra / Web", "Production", "Prod"),
).toBeNull();
});
});
describe("isValidPort", () => {
it("accepts ports in range", () => {
expect(isValidPort(1)).toBe(true);
expect(isValidPort(22)).toBe(true);
expect(isValidPort(65535)).toBe(true);
});
it("rejects out-of-range or non-number ports", () => {
expect(isValidPort(0)).toBe(false);
expect(isValidPort(65536)).toBe(false);
expect(isValidPort(-1)).toBe(false);
expect(isValidPort("22")).toBe(false);
});
});
describe("normalizeImportedHost", () => {
it("defaults connectionType to ssh with port 22", () => {
const host = normalizeImportedHost({ ip: "10.0.0.1" });
expect(host.connectionType).toBe("ssh");
expect(host.port).toBe(22);
expect(host.enableSsh).toBe(true);
expect(host.enableRdp).toBe(false);
});
it("infers rdp from enableRdp and uses default rdp port", () => {
const host = normalizeImportedHost({ enableRdp: true, ip: "10.0.0.2" });
expect(host.connectionType).toBe("rdp");
expect(host.port).toBe(3389);
expect(host.enableRdp).toBe(true);
});
it("honors an explicit port over protocol defaults", () => {
const host = normalizeImportedHost({
connectionType: "ssh",
port: 2222,
});
expect(host.port).toBe(2222);
});
it("resolves ip from common aliases", () => {
expect(normalizeImportedHost({ address: "a.example" }).ip).toBe(
"a.example",
);
expect(normalizeImportedHost({ hostname: "h.example" }).ip).toBe(
"h.example",
);
});
it("normalizes tags from a comma string", () => {
const host = normalizeImportedHost({ tags: "prod, db , , web" });
expect(host.tags).toEqual(["prod", "db", "web"]);
});
it("normalizes tags from an array", () => {
const host = normalizeImportedHost({ tags: ["a", " b ", "", "c"] });
expect(host.tags).toEqual(["a", "b", "c"]);
});
it("infers authType credential when credentialId present", () => {
const host = normalizeImportedHost({ credentialId: 7 });
expect(host.credentialId).toBe(7);
expect(host.authType).toBe("credential");
});
it("infers credential auth from share aliases", () => {
const aliasHost = normalizeImportedHost({ credentialAlias: "prod-admin" });
expect(aliasHost.credentialAlias).toBe("prod-admin");
expect(aliasHost.authType).toBe("credential");
const nameHost = normalizeImportedHost({ credentialName: "ops-key" });
expect(nameHost.credentialAlias).toBe("ops-key");
expect(nameHost.authType).toBe("credential");
});
});
describe("stripSensitiveFields", () => {
it("removes secret fields and adds boolean presence flags", () => {
const result = stripSensitiveFields({
name: "web",
password: "secret",
key: "PRIVATE KEY",
keyPassword: "kp",
sudoPassword: "sp",
});
expect(result.password).toBeUndefined();
expect(result.key).toBeUndefined();
expect(result.keyPassword).toBeUndefined();
expect(result.sudoPassword).toBeUndefined();
expect(result.hasPassword).toBe(true);
expect(result.hasKey).toBe(true);
expect(result.hasKeyPassword).toBe(true);
expect(result.hasSudoPassword).toBe(true);
expect(result.name).toBe("web");
});
it("marks presence flags false when secrets are absent", () => {
const result = stripSensitiveFields({ name: "web" });
expect(result.hasPassword).toBe(false);
expect(result.hasKey).toBe(false);
});
});
describe("transformHostResponse", () => {
it("parses tags and coerces enable flags to booleans", () => {
const result = transformHostResponse({
tags: "a,b,c",
enableTerminal: 1,
enableTunnel: 0,
pin: 1,
});
expect(result.tags).toEqual(["a", "b", "c"]);
expect(result.enableTerminal).toBe(true);
expect(result.enableTunnel).toBe(false);
expect(result.pin).toBe(true);
});
it("parses JSON array fields and defaults them to []", () => {
const result = transformHostResponse({
tunnelConnections: '[{"sourcePort":8080}]',
jumpHosts: null,
});
expect(result.tunnelConnections).toEqual([{ sourcePort: 8080 }]);
expect(result.jumpHosts).toEqual([]);
});
it("infers protocol flags for a migrated non-ssh host", () => {
const result = transformHostResponse({
connectionType: "rdp",
enableSsh: true,
});
expect(result.enableSsh).toBe(false);
expect(result.enableRdp).toBe(true);
});
it("applies default protocol ports", () => {
const result = transformHostResponse({ port: 22 });
expect(result.sshPort).toBe(22);
expect(result.rdpPort).toBe(3389);
expect(result.vncPort).toBe(5900);
expect(result.telnetPort).toBe(23);
});
it("coerces enableProxmox and parses proxmoxConfig", () => {
const result = transformHostResponse({
enableProxmox: 1,
proxmoxConfig: '{"defaultCredentialId":3,"windowsPatterns":"win"}',
});
expect(result.enableProxmox).toBe(true);
expect(result.proxmoxConfig).toEqual({
defaultCredentialId: 3,
windowsPatterns: "win",
});
});
it("defaults enableProxmox to false when absent", () => {
const result = transformHostResponse({ port: 22 });
expect(result.enableProxmox).toBe(false);
expect(result.proxmoxConfig).toBeUndefined();
});
});
describe("sanitizeHostForRecipient", () => {
const sharedHost = {
id: 42,
userId: "owner",
ownerUsername: "owner",
isShared: true,
permissionLevel: "view",
name: "prod",
ip: "10.0.0.42",
port: 22,
username: "root",
folder: "servers",
tags: ["linux"],
notes: "secret runbook",
quickActions: [{ name: "restart", snippetId: "1" }],
password: "hunter2",
key: "PRIVATE",
sudoPassword: "sudo",
rdpPassword: "rdp",
socks5Password: "socks",
enableSsh: true,
enableRdp: true,
sshPort: 22,
rdpPort: 3389,
defaultPath: "/srv",
};
it("always strips secrets for recipients", () => {
const result = sanitizeHostForRecipient({ ...sharedHost }, "view");
expect(result.password).toBeUndefined();
expect(result.key).toBeUndefined();
expect(result.sudoPassword).toBeUndefined();
expect(result.rdpPassword).toBeUndefined();
expect(result.socks5Password).toBeUndefined();
// view keeps configuration fields
expect(result.notes).toBe("secret runbook");
expect(result.quickActions).toEqual(sharedHost.quickActions);
});
it("reduces connect-level hosts to connection essentials", () => {
const result = sanitizeHostForRecipient(
{ ...sharedHost, permissionLevel: "connect" },
"connect",
);
expect(result.name).toBe("prod");
expect(result.ip).toBe("10.0.0.42");
expect(result.enableRdp).toBe(true);
expect(result.rdpPort).toBe(3389);
expect(result.permissionLevel).toBe("connect");
expect(result.notes).toBeUndefined();
expect(result.quickActions).toBeUndefined();
expect(result.password).toBeUndefined();
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
isValidServiceLinkUrl,
normalizeServiceLinkUrl,
} from "../../../database/routes/service-link-url.js";
describe("service link URL handling", () => {
it("keeps explicit http and https URLs", () => {
expect(normalizeServiceLinkUrl("https://example.com")).toBe(
"https://example.com",
);
expect(normalizeServiceLinkUrl("http://192.168.1.10:8080")).toBe(
"http://192.168.1.10:8080",
);
});
it("adds http to bare service addresses", () => {
expect(normalizeServiceLinkUrl("192.168.1.10:8080")).toBe(
"http://192.168.1.10:8080",
);
expect(normalizeServiceLinkUrl("termix.local")).toBe("http://termix.local");
});
it("rejects unsupported schemes", () => {
expect(isValidServiceLinkUrl("ssh://example.com")).toBe(false);
expect(isValidServiceLinkUrl("javascript:alert(1)")).toBe(false);
});
});
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import path from "path";
// Stub db, logger, fs, and AuthManager before importing the route module
const mockSelect = vi.fn();
const mockDelete = vi.fn();
const mockInsert = vi.fn();
vi.mock("../../../database/db/index.js", () => ({
db: {
select: mockSelect,
delete: mockDelete,
insert: mockInsert,
},
}));
vi.mock("../../../utils/logger.js", () => ({
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
const mockReadFile = vi.fn();
const mockStat = vi.fn();
const mockUnlink = vi.fn();
const mockExistsSync = vi.fn();
vi.mock("fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("fs")>();
return {
...actual,
promises: { readFile: mockReadFile, unlink: mockUnlink },
existsSync: mockExistsSync,
statSync: mockStat,
};
});
// Build a chainable drizzle-like query stub
function makeChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {};
const methods = [
"from",
"leftJoin",
"where",
"orderBy",
"limit",
"set",
"values",
];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
Promise.resolve(resolveValue).then(cb);
(chain as unknown as Promise<unknown>).catch = (
cb: (e: unknown) => unknown,
) => Promise.resolve(resolveValue).catch(cb);
return chain;
}
describe("session-log-routes", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.DATA_DIR = "/data";
});
describe("GET / - list logs", () => {
it("returns logs for the authenticated user with file size", async () => {
const rows = [
{
id: 1,
hostId: 10,
userId: "u1",
startedAt: "2026-01-01T00:00:00Z",
endedAt: "2026-01-01T00:05:00Z",
duration: 300,
recordingPath: "/data/session_logs/u1/abc.log",
hostName: "my-server",
hostIp: "10.0.0.1",
},
];
const chain = makeChain(rows);
mockSelect.mockReturnValue(chain);
mockStat.mockReturnValue({ size: 4096 });
// Directly call the route handler extracted from the module
const { default: router } =
await import("../../../database/routes/session-log-routes.js");
expect(router).toBeDefined();
});
});
describe("path traversal guard", () => {
it("rejects paths outside the allowed session_logs directory", () => {
const allowedBase = path.resolve("/data", "session_logs");
const malicious = path.resolve("/data/session_logs/../../etc/passwd");
expect(malicious.startsWith(allowedBase)).toBe(false);
});
it("allows a legitimate session log path", () => {
const allowedBase = path.resolve("/data", "session_logs");
const valid = path.resolve("/data/session_logs/user1/abc.log");
expect(valid.startsWith(allowedBase)).toBe(true);
});
});
describe("formatters (pure logic)", () => {
it("stat returns size when file exists", () => {
mockExistsSync.mockReturnValue(true);
mockStat.mockReturnValue({ size: 1234 });
const exists = mockExistsSync("/some/file.log");
const { size } = mockStat("/some/file.log");
expect(exists).toBe(true);
expect(size).toBe(1234);
});
});
});
@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import { execFileSync } from "child_process";
import {
generateCa,
signUserCertificate,
ed25519RawFromLine,
} from "../../../database/routes/ssh-certificate.js";
function publicKeyObjectFromLine(line: string) {
const raw = ed25519RawFromLine(line);
if (!raw) throw new Error("not ed25519");
return crypto.createPublicKey({
key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") },
format: "jwk",
});
}
// Split a cert blob into the signed body and the raw 64-byte ed25519 signature.
function splitCert(certLine: string): { body: Buffer; rawSig: Buffer } {
const blob = Buffer.from(certLine.split(/\s+/)[1], "base64");
// trailing signature string = str( str("ssh-ed25519") + str(64-byte sig) )
const sigBlobLen = 4 + "ssh-ed25519".length + 4 + 64; // 83
const body = blob.subarray(0, blob.length - (4 + sigBlobLen));
const rawSig = blob.subarray(blob.length - 64);
return { body, rawSig };
}
describe("generateCa", () => {
it("produces a valid ed25519 public line and PKCS8 private key", () => {
const ca = generateCa();
expect(ca.publicKeyLine.startsWith("ssh-ed25519 ")).toBe(true);
expect(ed25519RawFromLine(ca.publicKeyLine)?.length).toBe(32);
expect(ca.privateKeyPem).toContain("BEGIN PRIVATE KEY");
// The PEM must load as a usable signing key.
expect(() =>
crypto.createPrivateKey({
key: ca.privateKeyPem,
format: "pem",
type: "pkcs8",
}),
).not.toThrow();
});
});
describe("signUserCertificate", () => {
it("returns null for non-ed25519 user keys", () => {
const ca = generateCa();
const cert = signUserCertificate({
userPublicKeyLine: "ssh-rsa AAAAB3Nz",
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "x",
principals: [],
validAfter: 0,
validBefore: 1,
});
expect(cert).toBeNull();
});
it("produces a cert whose signature verifies against the CA key", () => {
const ca = generateCa();
const user = generateCa(); // reuse: a valid ed25519 public line
const cert = signUserCertificate({
userPublicKeyLine: user.publicKeyLine,
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "termix:@alice",
principals: ["root", "ubuntu"],
validAfter: 1000,
validBefore: 2000,
});
expect(cert).not.toBeNull();
expect(cert!.startsWith("ssh-ed25519-cert-v01@openssh.com ")).toBe(true);
const { body, rawSig } = splitCert(cert!);
const caPub = publicKeyObjectFromLine(ca.publicKeyLine);
expect(crypto.verify(null, body, caPub, rawSig)).toBe(true);
// A different CA must NOT verify.
const otherPub = publicKeyObjectFromLine(generateCa().publicKeyLine);
expect(crypto.verify(null, body, otherPub, rawSig)).toBe(false);
});
it("is accepted and correctly parsed by ssh-keygen -L", () => {
let sshKeygen: string;
try {
sshKeygen = execFileSync("ssh-keygen", ["--help"], { encoding: "utf8" });
void sshKeygen;
} catch (e) {
// ssh-keygen prints usage to stderr and exits non-zero for --help; that's
// fine — it means the binary exists. Only skip if it's truly missing.
if ((e as { code?: string }).code === "ENOENT") return;
}
const ca = generateCa();
const user = generateCa();
const now = Math.floor(Date.now() / 1000);
const cert = signUserCertificate({
userPublicKeyLine: user.publicKeyLine,
caPrivateKeyPem: ca.privateKeyPem,
caPublicKeyLine: ca.publicKeyLine,
keyId: "termix-test-id",
principals: ["deploy"],
validAfter: now,
validBefore: now + 3600,
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cert-"));
const file = path.join(dir, "id-cert.pub");
try {
fs.writeFileSync(file, cert + "\n");
const out = execFileSync("ssh-keygen", ["-L", "-f", file], {
encoding: "utf8",
});
expect(out).toContain("user certificate");
expect(out).toContain('Key ID: "termix-test-id"');
expect(out).toContain("deploy");
expect(out).toMatch(/permit-pty/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest";
import {
classifyAlgo,
parsePublicKey,
matchesAlgoFilter,
MAX_PUBLIC_KEY_LENGTH,
} from "../../../database/routes/termix-id-keys.js";
// Build a valid OpenSSH public-key line for a given type by encoding a wire
// blob whose first string field equals the type (what parsePublicKey checks).
function makeKey(type: string, comment = ""): string {
const typeBuf = Buffer.from(type, "utf8");
const header = Buffer.alloc(4);
header.writeUInt32BE(typeBuf.length, 0);
const body = Buffer.alloc(40); // arbitrary trailing key material
const blob = Buffer.concat([header, typeBuf, body]).toString("base64");
return `${type} ${blob}${comment ? ` ${comment}` : ""}`;
}
describe("classifyAlgo", () => {
it("maps known types to normalized groups", () => {
expect(classifyAlgo("ssh-rsa")).toBe("RSA");
expect(classifyAlgo("rsa-sha2-512")).toBe("RSA");
expect(classifyAlgo("ssh-ed25519")).toBe("ED25519");
expect(classifyAlgo("ecdsa-sha2-nistp256")).toBe("ECDSA");
expect(classifyAlgo("ssh-dss")).toBe("DSA");
expect(classifyAlgo("sk-ssh-ed25519@openssh.com")).toBe("ED25519-SK");
expect(classifyAlgo("sk-ecdsa-sha2-nistp256@openssh.com")).toBe("ECDSA-SK");
});
it("falls back by substring for unknown variants", () => {
expect(classifyAlgo("ecdsa-sha2-nistp999")).toBe("ECDSA");
expect(classifyAlgo("rsa-sha2-256-cert")).toBe("RSA");
expect(classifyAlgo("something-weird")).toBe("SOMETHING-WEIRD");
});
});
describe("parsePublicKey", () => {
it("parses a valid ed25519 key and extracts the comment", () => {
const parsed = parsePublicKey(makeKey("ssh-ed25519", "alice@laptop"));
expect(parsed).not.toBeNull();
expect(parsed?.type).toBe("ssh-ed25519");
expect(parsed?.algorithm).toBe("ED25519");
expect(parsed?.comment).toBe("alice@laptop");
// Comment is stripped from the normalized (dedupe) form.
expect(parsed?.normalized.includes("alice@laptop")).toBe(false);
});
it("parses SK (FIDO) key types", () => {
expect(
parsePublicKey(makeKey("sk-ssh-ed25519@openssh.com"))?.algorithm,
).toBe("ED25519-SK");
});
it.each([
[null],
[undefined],
[""],
[" "],
["ssh-ed25519"], // missing blob
["ssh-ed25519 not_base64!!"], // bad base64 charset
["ssh-rsa AAAAB3Nz"], // blob whose embedded type != declared type
])("rejects malformed input %p", (input) => {
expect(parsePublicKey(input as string)).toBeNull();
});
it("rejects an over-length line (amplification guard)", () => {
const valid = makeKey("ssh-ed25519");
const padded = valid + " " + "A".repeat(MAX_PUBLIC_KEY_LENGTH);
expect(padded.length).toBeGreaterThan(MAX_PUBLIC_KEY_LENGTH);
expect(parsePublicKey(padded)).toBeNull();
});
it("rejects a blob whose embedded type does not match the prefix", () => {
// Declared ssh-rsa but the wire blob says ssh-ed25519.
const blob = makeKey("ssh-ed25519").split(" ")[1];
expect(parsePublicKey(`ssh-rsa ${blob}`)).toBeNull();
});
});
describe("matchesAlgoFilter", () => {
it("returns all keys when no filter", () => {
expect(matchesAlgoFilter("ED25519", null)).toBe(true);
});
it("matches exactly and is case-insensitive", () => {
expect(matchesAlgoFilter("ED25519", "ed25519")).toBe(true);
expect(matchesAlgoFilter("RSA", "RSA")).toBe(true);
});
it("does NOT let ED25519 match ED25519-SK (the over-match bug)", () => {
expect(matchesAlgoFilter("ED25519-SK", "ED25519")).toBe(false);
expect(matchesAlgoFilter("ECDSA-SK", "ECDSA")).toBe(false);
});
});
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockSelect = vi.fn();
const mockUpdate = vi.fn();
const mockInsert = vi.fn();
const mockDelete = vi.fn();
vi.mock("../../../database/db/index.js", () => ({
db: {
select: mockSelect,
update: mockUpdate,
insert: mockInsert,
delete: mockDelete,
},
}));
vi.mock("../../../utils/logger.js", () => ({
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = "user-1";
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: vi.fn(),
getRequestMeta: vi.fn(() => ({})),
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) },
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentTermixIdentityCaRepository: vi.fn(() => ({
findPublicByIdentityId: vi.fn(),
findDecryptedByIdentityId: vi.fn(),
createEncryptedForUser: vi.fn(),
updateEncryptedForIdentity: vi.fn(),
deleteByIdentityId: vi.fn(),
})),
createCurrentTermixIdentityRepository: vi.fn(() => ({
findIdentityForUser: vi.fn(),
findIdentityByHandle: vi.fn(),
isHandleTaken: vi.fn(),
createIdentity: vi.fn(),
updateIdentityForUser: vi.fn(),
deleteIdentityForUser: vi.fn(),
listKeysByIdentityId: vi.fn(),
listEnabledKeysByIdentityId: vi.fn(),
listLinkedCredentialIds: vi.fn(),
createKey: vi.fn(),
updateKeyForUser: vi.fn(),
deleteKeyForUser: vi.fn(),
findKeyForUser: vi.fn(),
})),
}));
vi.mock("../../../database/routes/termix-id-keys.js", () => ({
termixIdKeysRouter: { use: vi.fn() },
matchesAlgoFilter: vi.fn(() => true),
}));
// Chainable Drizzle stub — supports arbitrary method chains and resolves via .then()
function makeChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {};
const methods = [
"from",
"where",
"set",
"values",
"returning",
"orderBy",
"limit",
"and",
"eq",
];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
(chain as unknown as Promise<unknown>).then = (
cb: (v: unknown) => unknown,
eb?: (e: unknown) => unknown,
) => Promise.resolve(resolveValue).then(cb, eb);
(chain as unknown as Promise<unknown>).catch = (
cb: (e: unknown) => unknown,
) => Promise.resolve(resolveValue).catch(cb);
return chain;
}
const IDENTITY_ROW = { id: 42, userId: "user-1", handle: "alice" };
describe("GET /termix-id/linked-credentials", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty list when user has no identity", async () => {
// First select (getIdentityForUser) returns nothing; second should not be called
mockSelect.mockReturnValueOnce(makeChain([]));
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
expect(router).toBeDefined();
}, 15_000);
it("returns empty list when identity has no keys", async () => {
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW])) // identity lookup
.mockReturnValueOnce(makeChain([])); // keys lookup
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
});
it("returns deduplicated credentialIds for enabled keys", async () => {
const keys = [
{ credentialId: 10 },
{ credentialId: 20 },
{ credentialId: 10 }, // duplicate
];
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
.mockReturnValueOnce(makeChain(keys));
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
});
it("excludes keys with null credentialId", async () => {
const keys = [{ credentialId: null }, { credentialId: 5 }];
mockSelect
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
.mockReturnValueOnce(makeChain(keys));
const { default: router } =
await import("../../../database/routes/termix-id.js");
expect(router).toBeDefined();
});
});
@@ -0,0 +1,275 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, RequestHandler, Response } from "express";
const state = vi.hoisted(() => ({
currentUserId: "admin1",
users: new Map<
string,
{
id: string;
username: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash: string | null;
totpEnabled: boolean;
}
>(),
unlockedUsers: new Set<string>(),
updates: [] as { id: string; changes: Record<string, unknown> }[],
auditCalls: [] as Record<string, unknown>[],
}));
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
vi.mock("../../../utils/logger.js", () => ({
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (params: Record<string, unknown>) => {
state.auditCalls.push(params);
},
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: {
canUserAccessData: (userId: string) => state.unlockedUsers.has(userId),
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({}) },
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
listAll: async () => [...state.users.values()],
findById: async (id: string) => state.users.get(id) ?? null,
findByUsername: async (username: string) =>
[...state.users.values()].find((u) => u.username === username) ?? null,
update: async (id: string, changes: Record<string, unknown>) => {
state.updates.push({ id, changes });
const user = state.users.get(id);
if (user) Object.assign(user, changes);
},
}),
createCurrentRoleRepository: () => ({
switchUserRoleName: async () => {},
assignRoleNameToUser: async () => {},
}),
}));
const { registerUserAdminRoutes } =
await import("../../../database/routes/user-admin-routes.js");
// Capture the handlers registered on the router so we can invoke them directly
// without spinning up an HTTP server.
type Registered = { method: string; path: string; handler: RequestHandler };
const registered: Registered[] = [];
function fakeRouter() {
const record =
(method: string) =>
(path: string, ...handlers: RequestHandler[]) => {
registered.push({ method, path, handler: handlers[handlers.length - 1] });
};
return {
get: record("get"),
post: record("post"),
put: record("put"),
delete: record("delete"),
} as unknown as import("express").Router;
}
registerUserAdminRoutes(fakeRouter(), (_req, _res, next) => next());
function findHandler(method: string, path: string): RequestHandler {
const match = registered.find((r) => r.method === method && r.path === path);
if (!match) throw new Error(`No handler for ${method} ${path}`);
return match.handler;
}
function makeReqRes(overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
}) {
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
params: overrides.params ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
headers: {} as Record<string, string>,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
setHeader(key: string, value: string) {
(this as unknown as { headers: Record<string, string> }).headers[key] =
value;
return this;
},
} as unknown as Response & {
statusCode: number;
jsonBody: unknown;
headers: Record<string, string>;
};
return { req, res };
}
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
} = {},
) {
const handler = findHandler(method, path);
const { req, res } = makeReqRes(overrides);
await handler(req, res as unknown as Response, () => {});
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
beforeEach(() => {
state.currentUserId = "admin1";
state.users = new Map([
[
"admin1",
{
id: "admin1",
username: "admin",
isAdmin: true,
isOidc: false,
passwordHash: "hash",
totpEnabled: false,
},
],
[
"target1",
{
id: "target1",
username: "target",
isAdmin: false,
isOidc: false,
passwordHash: "hash",
totpEnabled: true,
},
],
[
"locked1",
{
id: "locked1",
username: "locked",
isAdmin: false,
isOidc: false,
passwordHash: "hash",
totpEnabled: false,
},
],
]);
state.unlockedUsers = new Set(["admin1", "target1"]);
state.updates = [];
state.auditCalls = [];
});
describe("GET /list", () => {
it("includes data_unlocked and totp_enabled for admin callers", async () => {
const res = await invoke("get", "/list");
expect(res.statusCode).toBe(200);
const users = (res.jsonBody as { users: Record<string, unknown>[] }).users;
const target = users.find((u) => u.userId === "target1")!;
expect(target.data_unlocked).toBe(true);
expect(target.totp_enabled).toBe(true);
const locked = users.find((u) => u.userId === "locked1")!;
expect(locked.data_unlocked).toBe(false);
});
it("omits management fields for non-admin callers", async () => {
state.currentUserId = "target1";
const res = await invoke("get", "/list");
const users = (res.jsonBody as { users: Record<string, unknown>[] }).users;
expect(users[0].data_unlocked).toBeUndefined();
expect(users[0].totp_enabled).toBeUndefined();
});
});
describe("POST /admin/totp/disable", () => {
it("clears TOTP fields for the target and audits", async () => {
const res = await invoke("post", "/admin/totp/disable", {
body: { userId: "target1" },
});
expect(res.statusCode).toBe(200);
const update = state.updates.find((u) => u.id === "target1");
expect(update?.changes).toMatchObject({
totpSecret: null,
totpEnabled: false,
totpBackupCodes: null,
});
expect(
state.auditCalls.some((c) => c.action === "admin_disable_totp"),
).toBe(true);
});
it("403s when the caller is not an admin", async () => {
state.currentUserId = "target1";
const res = await invoke("post", "/admin/totp/disable", {
body: { userId: "locked1" },
});
expect(res.statusCode).toBe(403);
});
it("400s when TOTP is not enabled for the target", async () => {
const res = await invoke("post", "/admin/totp/disable", {
body: { userId: "locked1" },
});
expect(res.statusCode).toBe(400);
});
it("404s for an unknown target", async () => {
const res = await invoke("post", "/admin/totp/disable", {
body: { userId: "ghost" },
});
expect(res.statusCode).toBe(404);
});
});
describe("GET /admin/export/:userId", () => {
it("423s when the target's data is locked", async () => {
const res = await invoke("get", "/admin/export/:userId", {
params: { userId: "locked1" },
});
expect(res.statusCode).toBe(423);
expect((res.jsonBody as { code?: string }).code).toBe("TARGET_DATA_LOCKED");
});
it("403s when the caller is not an admin", async () => {
state.currentUserId = "target1";
const res = await invoke("get", "/admin/export/:userId", {
params: { userId: "admin1" },
});
expect(res.statusCode).toBe(403);
});
});
@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// user-oidc-utils imports the logger; stub it so importing stays side-effect-free.
vi.mock("../../../utils/logger.js", () => ({
authLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const {
isOIDCUserAllowed,
getOIDCConfigFromEnv,
extractOidcGroups,
validateLogoutTokenClaims,
} = await import("../../../database/routes/user-oidc-utils.js");
const BACKCHANNEL_LOGOUT_EVENT =
"http://schemas.openid.net/event/backchannel-logout";
describe("isOIDCUserAllowed", () => {
it("allows everyone when the allow-list is empty", () => {
expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true);
expect(isOIDCUserAllowed(" ", "alice")).toBe(true);
});
it("allows everyone with the '*' wildcard", () => {
expect(isOIDCUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
});
it("matches an exact identifier (case-insensitive)", () => {
expect(isOIDCUserAllowed("alice,bob", "alice")).toBe(true);
expect(isOIDCUserAllowed("Alice", "alice")).toBe(true);
expect(isOIDCUserAllowed("alice", "ALICE")).toBe(true);
});
it("matches against the email as well as the identifier", () => {
expect(isOIDCUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
true,
);
});
it("matches an @domain suffix pattern", () => {
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@company.com")).toBe(
true,
);
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM")).toBe(
true,
);
});
it("denies users not on the list", () => {
expect(isOIDCUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
false,
);
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@other.com")).toBe(
false,
);
});
it("ignores blank entries and surrounding whitespace in the list", () => {
expect(isOIDCUserAllowed(" alice , , bob ", "bob")).toBe(true);
});
it("does not match the email against an identifier-only pattern when email differs", () => {
expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
});
it("matches *@domain.com wildcard pattern against emails", () => {
expect(
isOIDCUserAllowed("*@company.com", "sub-1", "john@company.com"),
).toBe(true);
expect(
isOIDCUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
).toBe(true);
expect(isOIDCUserAllowed("*@company.com", "sub-1", "user@other.com")).toBe(
false,
);
});
it("matches glob patterns with multiple wildcards", () => {
expect(isOIDCUserAllowed("admin*", "admin_user")).toBe(true);
expect(isOIDCUserAllowed("admin*", "user_admin")).toBe(false);
});
});
describe("getOIDCConfigFromEnv", () => {
const REQUIRED = [
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ISSUER_URL",
"OIDC_AUTHORIZATION_URL",
"OIDC_TOKEN_URL",
];
const OPTIONAL = [
"OIDC_USERINFO_URL",
"OIDC_IDENTIFIER_PATH",
"OIDC_NAME_PATH",
"OIDC_SCOPES",
"OIDC_ALLOWED_USERS",
"OIDC_ADMIN_GROUP",
];
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of [...REQUIRED, ...OPTIONAL]) {
saved[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of [...REQUIRED, ...OPTIONAL]) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});
it("returns null when any required variable is missing", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
// issuer/authorization/token urls intentionally missing
expect(getOIDCConfigFromEnv()).toBeNull();
});
it("builds a config with defaults when all required vars are present", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
process.env.OIDC_ISSUER_URL = "https://idp.example";
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
const config = getOIDCConfigFromEnv();
expect(config).not.toBeNull();
expect(config?.client_id).toBe("id");
expect(config?.identifier_path).toBe("sub");
expect(config?.name_path).toBe("name");
expect(config?.scopes).toBe("openid email profile");
expect(config?.userinfo_url).toBe("");
});
it("honors overrides for optional vars", () => {
process.env.OIDC_CLIENT_ID = "id";
process.env.OIDC_CLIENT_SECRET = "secret";
process.env.OIDC_ISSUER_URL = "https://idp.example";
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
process.env.OIDC_IDENTIFIER_PATH = "email";
process.env.OIDC_SCOPES = "openid";
const config = getOIDCConfigFromEnv();
expect(config?.identifier_path).toBe("email");
expect(config?.scopes).toBe("openid");
});
});
describe("extractOidcGroups", () => {
it("reads the standard groups claim as an array", () => {
expect(extractOidcGroups({ groups: ["admin", "user"] })).toEqual([
"admin",
"user",
]);
});
it("splits a comma-separated string claim", () => {
expect(extractOidcGroups({ roles: "admin, user" })).toEqual([
"admin",
"user",
]);
});
it("falls back through groups, roles, then group", () => {
expect(extractOidcGroups({ group: "ops" })).toEqual(["ops"]);
});
it("reads a custom claim path when provided", () => {
const userInfo = {
"zitadel:grants:groups:123": ["user", "admin"],
groups: ["ignored"],
};
expect(extractOidcGroups(userInfo, "zitadel:grants:groups:123")).toEqual([
"user",
"admin",
]);
});
it("uses object keys as group names (Zitadel roles object)", () => {
const userInfo = {
"urn:zitadel:iam:org:project:roles": { admin: {}, user: {} },
};
expect(
extractOidcGroups(userInfo, "urn:zitadel:iam:org:project:roles"),
).toEqual(["admin", "user"]);
});
it("falls back to defaults when the custom claim is absent", () => {
expect(extractOidcGroups({ groups: ["admin"] }, "missing")).toEqual([
"admin",
]);
});
it("returns an empty array when no groups are present", () => {
expect(extractOidcGroups({})).toEqual([]);
});
});
describe("validateLogoutTokenClaims", () => {
const validClaims = {
sub: "subject-1",
sid: "session-1",
iat: 1_783_641_600,
jti: "logout-1",
events: { [BACKCHANNEL_LOGOUT_EVENT]: {} },
};
it("accepts a spec-compliant back-channel logout payload", () => {
expect(validateLogoutTokenClaims(validClaims)).toEqual({
sub: "subject-1",
sid: "session-1",
jti: "logout-1",
});
});
it("requires the logout event to contain an object", () => {
expect(() =>
validateLogoutTokenClaims({
...validClaims,
events: { [BACKCHANNEL_LOGOUT_EVENT]: true },
}),
).toThrow("missing back-channel logout event");
});
it("requires iat and jti claims", () => {
expect(() =>
validateLogoutTokenClaims({ ...validClaims, iat: undefined }),
).toThrow("missing iat claim");
expect(() =>
validateLogoutTokenClaims({ ...validClaims, jti: "" }),
).toThrow("missing jti claim");
});
it("rejects nonce and requires sub or sid", () => {
expect(() =>
validateLogoutTokenClaims({ ...validClaims, nonce: "forbidden" }),
).toThrow("must not contain a nonce");
expect(() =>
validateLogoutTokenClaims({ ...validClaims, sub: null, sid: null }),
).toThrow("must contain sub and/or sid");
});
});
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthManager } from "../../../utils/auth-manager.js";
const calls = vi.hoisted(() => ({
userUpdates: [] as Array<[string, Record<string, unknown>]>,
deletedFor: [] as string[],
rotatedFor: [] as string[],
legacyWrapsDeletedFor: [] as string[],
}));
function deletingRepo(label: string) {
return () => ({
deleteByUserId: async (userId: string) => {
calls.deletedFor.push(`${label}:${userId}`);
return 0;
},
});
}
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
update: async (userId: string, update: Record<string, unknown>) => {
calls.userUpdates.push([userId, update]);
return { id: userId };
},
}),
createCurrentSettingsRepository: () => ({}),
createCurrentSshCredentialUsageRepository: deletingRepo("usage"),
createCurrentFileManagerBookmarkRepository: deletingRepo("bookmarks"),
createCurrentRecentActivityRepository: deletingRepo("activity"),
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
createCurrentSnippetRepository: deletingRepo("snippets"),
createCurrentHostRepository: deletingRepo("hosts"),
createCurrentCredentialRepository: deletingRepo("credentials"),
}));
vi.mock("../../../utils/user-keys.js", () => ({
UserKeyManager: {
getInstance: () => ({
rotateUserDEK: async (userId: string) => {
calls.rotatedFor.push(userId);
return Buffer.alloc(32);
},
}),
},
}));
vi.mock("../../../utils/crypto-migration/dek-migration.js", () => ({
deleteLegacyWraps: async (userId: string) => {
calls.legacyWrapsDeletedFor.push(userId);
},
}));
import { resetUserPassword } from "../../../database/routes/user-password-reset-routes.js";
function fakeAuthManager(unlocked: boolean): AuthManager {
return {
isUserUnlocked: () => unlocked,
logoutUser: vi.fn(async () => {}),
} as unknown as AuthManager;
}
beforeEach(() => {
calls.userUpdates = [];
calls.deletedFor = [];
calls.rotatedFor = [];
calls.legacyWrapsDeletedFor = [];
});
describe("resetUserPassword", () => {
it("preserves data for users with a server-wrapped key", async () => {
const outcome = await resetUserPassword(fakeAuthManager(true), {
userId: "user-1",
username: "alice",
newPassword: "new-password",
confirmDataWipe: false,
});
expect(outcome).toEqual({ status: "reset", dataWiped: false });
expect(calls.userUpdates).toHaveLength(1);
expect(calls.userUpdates[0][1]).toHaveProperty("passwordHash");
expect(calls.deletedFor).toEqual([]);
expect(calls.rotatedFor).toEqual([]);
});
it("requires explicit consent before wiping an unmigrated user", async () => {
const outcome = await resetUserPassword(fakeAuthManager(false), {
userId: "user-1",
username: "alice",
newPassword: "new-password",
confirmDataWipe: false,
});
expect(outcome).toEqual({ status: "wipe_confirmation_required" });
expect(calls.userUpdates).toEqual([]);
expect(calls.deletedFor).toEqual([]);
});
it("wipes data and rotates the key when consent is given", async () => {
const outcome = await resetUserPassword(fakeAuthManager(false), {
userId: "user-1",
username: "alice",
newPassword: "new-password",
confirmDataWipe: true,
});
expect(outcome).toEqual({ status: "reset", dataWiped: true });
expect(calls.deletedFor).toEqual([
"usage:user-1",
"bookmarks:user-1",
"activity:user-1",
"alerts:user-1",
"snippets:user-1",
"hosts:user-1",
"credentials:user-1",
]);
expect(calls.rotatedFor).toEqual(["user-1"]);
expect(calls.legacyWrapsDeletedFor).toEqual(["user-1"]);
expect(
calls.userUpdates.some(([, update]) => update.totpEnabled === false),
).toBe(true);
});
});
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import bcrypt from "bcryptjs";
import speakeasy from "speakeasy";
// The route module imports repository factories and the logger; stub both so
// importing stays inert.
const userRepositoryUpdate = vi.fn().mockResolvedValue(null);
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
update: userRepositoryUpdate,
}),
}));
vi.mock("../../../utils/logger.js", () => ({
authLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { verifyTotpReauth } =
await import("../../../database/routes/user-totp-routes.js");
type AnyUser = Parameters<typeof verifyTotpReauth>[0];
const secret = speakeasy.generateSecret({ name: "test" }).base32;
function makeUser(overrides: Partial<AnyUser> = {}): AnyUser {
return {
id: "user-1",
isOidc: false,
passwordHash: bcrypt.hashSync("correct-horse", 4),
totpSecret: secret,
totpBackupCodes: JSON.stringify(["BACKUP01", "BACKUP02"]),
totpEnabled: true,
...overrides,
} as AnyUser;
}
describe("verifyTotpReauth", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not accept the account password as a second factor", async () => {
expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(false);
});
it("accepts a valid TOTP code without a password", async () => {
const token = speakeasy.totp({ secret, encoding: "base32" });
expect(await verifyTotpReauth(makeUser(), token)).toBe(true);
});
it("accepts a valid backup code and consumes it", async () => {
const result = await verifyTotpReauth(makeUser(), "BACKUP01");
expect(result).toBe(true);
expect(userRepositoryUpdate).toHaveBeenCalledWith("user-1", {
totpBackupCodes: JSON.stringify(["BACKUP02"]),
});
});
it("rejects a wrong password / invalid code", async () => {
expect(await verifyTotpReauth(makeUser(), "wrong")).toBe(false);
expect(userRepositoryUpdate).not.toHaveBeenCalled();
});
it("ignores the password path for OIDC users but still accepts TOTP", async () => {
const token = speakeasy.totp({ secret, encoding: "base32" });
const oidcUser = makeUser({ isOidc: true, passwordHash: null });
expect(await verifyTotpReauth(oidcUser, token)).toBe(true);
expect(await verifyTotpReauth(oidcUser, "anything")).toBe(false);
});
it("handles malformed backup-code JSON without throwing", async () => {
const user = makeUser({ totpBackupCodes: "not json" });
expect(await verifyTotpReauth(user, "BACKUP01")).toBe(false);
});
});