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>
This commit is contained in:
ZacharyZcR
2026-07-16 12:28:10 +08:00
committed by GitHub
parent ed6cda4ea4
commit 1e7c9ea53c
229 changed files with 24327 additions and 8382 deletions
@@ -0,0 +1,383 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
import { SnippetRepository } from "./snippet-repository.js";
describe("SnippetRepository", () => {
let adapter: SqliteDatabaseAdapter | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite?: () => void): Promise<{
repository: SnippetRepository;
sqlite: NonNullable<
Awaited<ReturnType<SqliteDatabaseAdapter["connect"]>>["sqlite"]
>;
}> {
adapter = new SqliteDatabaseAdapter({
dialect: "sqlite",
url: ":memory:",
sqlitePath: ":memory:",
});
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);
});
});