From ab911265edc580bdec4c10819bbab14a5d88e345 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 14 May 2026 23:41:02 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20banir=20SQLite=20direto=20?= =?UTF-8?q?=E2=80=94=20withRuntime=20+=20src/lib/db/*=20modules=20(Fase=20?= =?UTF-8?q?1.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .semgrep/rules/cli-no-sqlite.yaml | 31 +++ bin/cli/CONVENTIONS.md | 20 +- bin/cli/commands/combo.mjs | 247 ++++++++++++------- bin/cli/commands/reset-encrypted-columns.mjs | 66 ++--- bin/cli/runtime.mjs | 43 ++-- src/lib/db/combos.ts | 12 + src/lib/db/recovery.ts | 33 +++ tests/unit/cli-combo-command.test.ts | 76 ++---- tests/unit/db-recovery.test.ts | 75 ++++++ 9 files changed, 371 insertions(+), 232 deletions(-) create mode 100644 .semgrep/rules/cli-no-sqlite.yaml create mode 100644 src/lib/db/recovery.ts create mode 100644 tests/unit/db-recovery.test.ts diff --git a/.semgrep/rules/cli-no-sqlite.yaml b/.semgrep/rules/cli-no-sqlite.yaml new file mode 100644 index 0000000000..7c9e9fe508 --- /dev/null +++ b/.semgrep/rules/cli-no-sqlite.yaml @@ -0,0 +1,31 @@ +rules: + - id: cli-no-sqlite-direct + patterns: + - pattern: new Database(...) + paths: + include: + - "bin/**" + exclude: + - "bin/cli/sqlite.mjs" + message: > + Direct SQLite access in bin/ is banned. Use src/lib/db/* helpers or + withRuntime() from bin/cli/runtime.mjs. See CLAUDE.md hard rule #5 and + bin/cli/CONVENTIONS.md. + languages: [js] + severity: ERROR + + - id: cli-no-raw-sql + patterns: + - pattern: $DB.prepare("INSERT INTO ...") + - pattern: $DB.prepare("DELETE FROM ...") + - pattern: $DB.prepare("UPDATE $TABLE SET ...") + paths: + include: + - "bin/**" + exclude: + - "bin/cli/sqlite.mjs" + message: > + Raw SQL in bin/ is banned. Use src/lib/db/* helpers. See CLAUDE.md + hard rule #5 and bin/cli/CONVENTIONS.md. + languages: [js] + severity: ERROR diff --git a/bin/cli/CONVENTIONS.md b/bin/cli/CONVENTIONS.md index 181f3b2f91..86e94d94db 100644 --- a/bin/cli/CONVENTIONS.md +++ b/bin/cli/CONVENTIONS.md @@ -155,18 +155,24 @@ Single helper: ```js import { withRuntime } from "./runtime.mjs"; -await withRuntime(async (ctx) => { - if (ctx.kind === "http") return ctx.api("/v1/providers"); - return ctx.db.providers.list(); +await withRuntime(async ({ kind, api, db }) => { + if (kind === "http") + return api("/api/combos", { retry: false, timeout: 5000, acceptNotOk: true }); + return db.combos.getCombos(); }); ``` -- `kind: "http"` when server is up (preferred). -- `kind: "db"` when offline (read-only operations). +- `kind: "http"` when server is up (preferred). `api` is `apiFetch` bound to + the current profile/base-URL. +- `kind: "db"` when server is offline. `db` exposes typed module exports: + - `db.combos` → `src/lib/db/combos.ts` (getCombos, getComboByName, createCombo, + deleteComboByName, setActiveCombo, …) + - `db.recovery` → `src/lib/db/recovery.ts` (countEncryptedCredentials, + resetEncryptedColumns) - Mutations that require server **must** error with exit code `3` when the server is down, never silently fall back. -- **Never** write raw SQL in commands — always go through `bin/cli/sqlite.mjs` - or the upstream `src/lib/db/` modules. +- **Never** write raw SQL in commands — always go through `src/lib/db/` modules. + The Semgrep rule at `.semgrep/rules/cli-no-sqlite.yaml` enforces this at commit time. ## 9. Audit of destructive actions diff --git a/bin/cli/commands/combo.mjs b/bin/cli/commands/combo.mjs index 07678a1b4d..d2a156bedc 100644 --- a/bin/cli/commands/combo.mjs +++ b/bin/cli/commands/combo.mjs @@ -1,7 +1,6 @@ import { Option } from "commander"; import { printHeading } from "../io.mjs"; -import { openOmniRouteDb } from "../sqlite.mjs"; -import { apiFetch, isServerUp } from "../api.mjs"; +import { withRuntime } from "../runtime.mjs"; import { t } from "../i18n.mjs"; const VALID_STRATEGIES = [ @@ -72,51 +71,54 @@ export function registerCombo(program) { } export async function runComboListCommand(opts = {}) { - const { db } = await openOmniRouteDb(); try { - // TODO(1.5): replace raw SQL with src/lib/db/combos.ts - const combos = db - .prepare("SELECT id, name, strategy, enabled, target_count FROM combos ORDER BY name") - .all(); + return await withRuntime(async ({ kind, api, db }) => { + let combos = []; + let activeCombo = null; - let activeCombo = null; - try { - const serverUp = await isServerUp(); - if (serverUp) { - const res = await apiFetch("/api/combos/active", { - retry: false, - timeout: 3000, - acceptNotOk: true, - }); - if (res.ok) { - const data = await res.json(); - activeCombo = data.active || data.name || data.combo || null; + if (kind === "http") { + const [listRes, activeRes] = await Promise.all([ + api("/api/combos", { retry: false, timeout: 5000, acceptNotOk: true }), + api("/api/settings", { retry: false, timeout: 3000, acceptNotOk: true }), + ]); + if (listRes.ok) { + const data = await listRes.json(); + combos = Array.isArray(data) ? data : (data.combos ?? []); } + if (activeRes.ok) { + const settings = await activeRes.json(); + activeCombo = settings?.activeCombo ?? null; + } + } else { + combos = await db.combos.getCombos(); + } + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify({ combos, active: activeCombo }, null, 2)); + return 0; + } + + printHeading(t("combo.title")); + if (combos.length === 0) { + console.log(t("combo.noCombos")); + return 0; + } + + for (const combo of combos) { + const comboName = combo.name ?? combo.id ?? "?"; + const isActive = activeCombo && (comboName === activeCombo || combo.id === activeCombo); + const icon = isActive ? "\x1b[32m●\x1b[0m" : "\x1b[2m○\x1b[0m"; + const enabled = combo.enabled !== false; + const status = enabled ? "\x1b[32menabled\x1b[0m" : "\x1b[31mdisabled\x1b[0m"; + const strategy = (combo.strategy ?? "priority").padEnd(12); + console.log(` ${icon} ${comboName.padEnd(25)} [${strategy}] ${status}`); } - } catch {} - if (opts.json || opts.output === "json") { - console.log(JSON.stringify({ combos, active: activeCombo }, null, 2)); return 0; - } - - printHeading(t("combo.title")); - if (combos.length === 0) { - console.log(t("combo.noCombos")); - return 0; - } - - for (const combo of combos) { - const isActive = activeCombo && (combo.name === activeCombo || combo.id === activeCombo); - const icon = isActive ? "\x1b[32m●\x1b[0m" : "\x1b[2m○\x1b[0m"; - const status = combo.enabled ? "\x1b[32menabled\x1b[0m" : "\x1b[31mdisabled\x1b[0m"; - const strategy = (combo.strategy || "priority").padEnd(12); - console.log(` ${icon} ${combo.name.padEnd(25)} [${strategy}] ${status}`); - } - - return 0; - } finally { - db.close(); + }); + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; } } @@ -126,40 +128,50 @@ export async function runComboSwitchCommand(name, opts = {}) { return 1; } - const serverUp = await isServerUp(); - if (serverUp) { - try { - const res = await apiFetch("/api/combos/switch", { - method: "POST", - body: { name }, - retry: false, - acceptNotOk: true, - }); - if (res.ok) { - console.log(t("combo.switched", { name })); - return 0; - } - } catch {} - } - - // DB fallback - const { db } = await openOmniRouteDb(); try { - // TODO(1.5): replace raw SQL with src/lib/db/combos.ts - const combo = db.prepare("SELECT id FROM combos WHERE name = ?").get(name); - if (!combo) { - console.error(`Combo '${name}' not found.`); - return 1; - } + return await withRuntime(async ({ kind, api, db }) => { + if (kind === "http") { + const listRes = await api("/api/combos", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (!listRes.ok) { + console.error(`Failed to fetch combo list (HTTP ${listRes.status}).`); + return 1; + } + const data = await listRes.json(); + const combos = Array.isArray(data) ? data : (data.combos ?? []); + const found = combos.find((c) => c.name === name || c.id === name); + if (!found) { + console.error(`Combo '${name}' not found.`); + return 1; + } + const patchRes = await api("/api/settings", { + method: "PATCH", + body: { activeCombo: name }, + retry: false, + acceptNotOk: true, + }); + if (!patchRes.ok) { + console.error(`Failed to switch combo (HTTP ${patchRes.status}).`); + return 1; + } + } else { + const combo = await db.combos.getComboByName(name); + if (!combo) { + console.error(`Combo '${name}' not found.`); + return 1; + } + db.combos.setActiveCombo(name); + } - db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'activeCombo', ?)" - ).run(JSON.stringify(name)); - - console.log(t("combo.switched", { name })); - return 0; - } finally { - db.close(); + console.log(t("combo.switched", { name })); + return 0; + }); + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; } } @@ -174,23 +186,36 @@ export async function runComboCreateCommand(name, strategy = "priority", opts = return 1; } - const { db } = await openOmniRouteDb(); try { - // TODO(1.5): replace raw SQL with src/lib/db/combos.ts - const existing = db.prepare("SELECT id FROM combos WHERE name = ?").get(name); - if (existing) { - console.error(`Combo '${name}' already exists. Delete it first.`); - return 1; - } + return await withRuntime(async ({ kind, api, db }) => { + if (kind === "http") { + const res = await api("/api/combos", { + method: "POST", + body: { name, strategy, enabled: true, models: [], config: {} }, + retry: false, + acceptNotOk: true, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + const msg = body ? ` — ${body}` : ""; + console.error(`Failed to create combo (HTTP ${res.status})${msg}`); + return 1; + } + } else { + const existing = await db.combos.getComboByName(name); + if (existing) { + console.error(`Combo '${name}' already exists. Delete it first.`); + return 1; + } + await db.combos.createCombo({ name, strategy, enabled: true, models: [], config: {} }); + } - db.prepare( - "INSERT INTO combos (name, strategy, enabled, target_count) VALUES (?, ?, 1, 0)" - ).run(name, strategy); - - console.log(t("combo.created", { name })); - return 0; - } finally { - db.close(); + console.log(t("combo.created", { name })); + return 0; + }); + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); + return 1; } } @@ -213,17 +238,47 @@ export async function runComboDeleteCommand(name, opts = {}) { } } - const { db } = await openOmniRouteDb(); try { - // TODO(1.5): replace raw SQL with src/lib/db/combos.ts - const result = db.prepare("DELETE FROM combos WHERE name = ?").run(name); - if (result.changes > 0) { + return await withRuntime(async ({ kind, api, db }) => { + if (kind === "http") { + const listRes = await api("/api/combos", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (!listRes.ok) { + console.error(`Failed to fetch combo list (HTTP ${listRes.status}).`); + return 1; + } + const data = await listRes.json(); + const combos = Array.isArray(data) ? data : (data.combos ?? []); + const found = combos.find((c) => c.name === name || c.id === name); + if (!found) { + console.error(`Combo '${name}' not found.`); + return 1; + } + const delRes = await api(`/api/combos/${encodeURIComponent(found.id)}`, { + method: "DELETE", + retry: false, + acceptNotOk: true, + }); + if (!delRes.ok) { + console.error(`Failed to delete combo (HTTP ${delRes.status}).`); + return 1; + } + } else { + const deleted = await db.combos.deleteComboByName(name); + if (!deleted) { + console.error(`Combo '${name}' not found.`); + return 1; + } + } + console.log(t("combo.deleted", { name })); return 0; - } - console.error(`Combo '${name}' not found.`); + }); + } catch (err) { + console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); return 1; - } finally { - db.close(); } } diff --git a/bin/cli/commands/reset-encrypted-columns.mjs b/bin/cli/commands/reset-encrypted-columns.mjs index 42803afcb9..165c85eb1b 100644 --- a/bin/cli/commands/reset-encrypted-columns.mjs +++ b/bin/cli/commands/reset-encrypted-columns.mjs @@ -1,21 +1,13 @@ -import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; import { existsSync } from "node:fs"; +import { resolveDataDir } from "../data-dir.mjs"; import { join } from "node:path"; -import { homedir, platform } from "node:os"; + +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); export async function runResetEncryptedColumns(argv) { - const dataDir = (() => { - const configured = process.env.DATA_DIR?.trim(); - if (configured) return configured; - if (platform() === "win32") { - const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming"); - return join(appData, "omniroute"); - } - const xdg = process.env.XDG_CONFIG_HOME?.trim(); - if (xdg) return join(xdg, "omniroute"); - return join(homedir(), ".omniroute"); - })(); - + const dataDir = resolveDataDir(); const dbPath = join(dataDir, "storage.sqlite"); if (!existsSync(dbPath)) { @@ -23,7 +15,8 @@ export async function runResetEncryptedColumns(argv) { return 0; } - const force = argv.includes("--force"); + const force = Array.isArray(argv) ? argv.includes("--force") : argv?.force === true; + if (!force) { console.log(` \x1b[1m\x1b[33m⚠ WARNING: This will erase all encrypted credentials\x1b[0m @@ -46,51 +39,28 @@ export async function runResetEncryptedColumns(argv) { } try { - const require = createRequire(import.meta.url); - const Database = require("better-sqlite3"); - const db = new Database(dbPath); + const { countEncryptedCredentials, resetEncryptedColumns } = await import( + `${PROJECT_ROOT}/src/lib/db/recovery.ts` + ); - const countResult = db - .prepare( - `SELECT COUNT(*) as cnt FROM provider_connections - WHERE api_key LIKE 'enc:v1:%' - OR access_token LIKE 'enc:v1:%' - OR refresh_token LIKE 'enc:v1:%' - OR id_token LIKE 'enc:v1:%'` - ) - .get(); + const count = countEncryptedCredentials(); - const affected = countResult?.cnt ?? 0; - - if (affected === 0) { + if (count === 0) { console.log("\x1b[32m✔ No encrypted credentials found — nothing to reset.\x1b[0m"); - db.close(); return 0; } - const result = db - .prepare( - `UPDATE provider_connections - SET api_key = NULL, - access_token = NULL, - refresh_token = NULL, - id_token = NULL - WHERE api_key LIKE 'enc:v1:%' - OR access_token LIKE 'enc:v1:%' - OR refresh_token LIKE 'enc:v1:%' - OR id_token LIKE 'enc:v1:%'` - ) - .run(); - - db.close(); + const { affected } = resetEncryptedColumns({ dryRun: false }); console.log( - `\x1b[32m✔ Reset ${result.changes} provider connection(s).\x1b[0m\n` + + `\x1b[32m✔ Reset ${affected} provider connection(s).\x1b[0m\n` + ` Re-authenticate your providers in the dashboard or re-add API keys.\n` ); return 0; } catch (err) { - console.error(`\x1b[31m✖ Failed to reset encrypted columns:\x1b[0m ${err.message || err}`); + console.error( + `\x1b[31m✖ Failed to reset encrypted columns:\x1b[0m ${err instanceof Error ? err.message : String(err)}` + ); return 1; } } diff --git a/bin/cli/runtime.mjs b/bin/cli/runtime.mjs index ba02d60ee5..6811896759 100644 --- a/bin/cli/runtime.mjs +++ b/bin/cli/runtime.mjs @@ -1,5 +1,8 @@ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; import { apiFetch, isServerUp } from "./api.mjs"; -import { openOmniRouteDb } from "./sqlite.mjs"; + +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); export class ServerOfflineError extends Error { constructor(message = "Server is offline and operation requires HTTP runtime") { @@ -17,21 +20,17 @@ function makeHttpContext(opts) { }; } +async function importDbModules() { + const [combos, recovery] = await Promise.all([ + import(`${PROJECT_ROOT}/src/lib/db/combos.ts`), + import(`${PROJECT_ROOT}/src/lib/db/recovery.ts`), + ]); + return { combos, recovery }; +} + async function makeDbContext() { - const { db, dataDir, dbPath } = await openOmniRouteDb(); - return { - kind: "db", - db, - dataDir, - dbPath, - close: () => { - try { - db.close(); - } catch { - // best-effort - } - }, - }; + const modules = await importDbModules(); + return { kind: "db", db: modules }; } export async function withRuntime(fn, opts = {}) { @@ -48,12 +47,7 @@ export async function withRuntime(fn, opts = {}) { } } - const ctx = await makeDbContext(); - try { - return await fn(ctx); - } finally { - ctx.close?.(); - } + return fn(await makeDbContext()); } export async function withHttp(fn, opts = {}) { @@ -63,10 +57,5 @@ export async function withHttp(fn, opts = {}) { } export async function withDb(fn) { - const ctx = await makeDbContext(); - try { - return await fn(ctx); - } finally { - ctx.close?.(); - } + return fn(await makeDbContext()); } diff --git a/src/lib/db/combos.ts b/src/lib/db/combos.ts index 8719618432..980d9cee44 100644 --- a/src/lib/db/combos.ts +++ b/src/lib/db/combos.ts @@ -260,3 +260,15 @@ export async function deleteCombo(id: string) { backupDbFile("pre-write"); return true; } + +export async function deleteComboByName(name: string) { + const combo = await getComboByName(name); + if (!combo || typeof combo.id !== "string") return false; + return deleteCombo(combo.id); +} + +export function setActiveCombo(name: string, db = getDbInstance()) { + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'activeCombo', ?)" + ).run(JSON.stringify(name)); +} diff --git a/src/lib/db/recovery.ts b/src/lib/db/recovery.ts new file mode 100644 index 0000000000..daf2fd9dc3 --- /dev/null +++ b/src/lib/db/recovery.ts @@ -0,0 +1,33 @@ +import { getDbInstance } from "./core"; + +type DbInstance = ReturnType; + +const ENCRYPTED_COLUMNS = ["api_key", "access_token", "refresh_token", "id_token"] as const; + +const ENCRYPTED_PATTERN = "enc:v1:%"; + +function buildWhereClause(): string { + return ENCRYPTED_COLUMNS.map((col) => `${col} LIKE '${ENCRYPTED_PATTERN}'`).join(" OR "); +} + +export function countEncryptedCredentials(db: DbInstance = getDbInstance()): number { + const where = buildWhereClause(); + const row = db + .prepare(`SELECT COUNT(*) AS cnt FROM provider_connections WHERE ${where}`) + .get() as { cnt: number } | undefined; + return row?.cnt ?? 0; +} + +export function resetEncryptedColumns( + { dryRun }: { dryRun: boolean }, + db: DbInstance = getDbInstance() +): { affected: number } { + const affected = countEncryptedCredentials(db); + if (dryRun || affected === 0) return { affected }; + + const nullCols = ENCRYPTED_COLUMNS.map((col) => `${col} = NULL`).join(", "); + const where = buildWhereClause(); + db.prepare(`UPDATE provider_connections SET ${nullCols} WHERE ${where}`).run(); + + return { affected }; +} diff --git a/tests/unit/cli-combo-command.test.ts b/tests/unit/cli-combo-command.test.ts index 4b177db2a6..40c3f44f3a 100644 --- a/tests/unit/cli-combo-command.test.ts +++ b/tests/unit/cli-combo-command.test.ts @@ -3,49 +3,27 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import Database from "better-sqlite3"; const ORIGINAL_DATA_DIR = process.env.DATA_DIR; const ORIGINAL_FETCH = globalThis.fetch; -interface ComboRow { - id: number; - name: string; - strategy: string; - enabled: number; -} - function createTempDataDir() { return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-combo-")); } -function initComboTable(dbPath: string) { - const db = new Database(dbPath); - db.pragma("journal_mode = WAL"); - db.prepare( - "CREATE TABLE IF NOT EXISTS key_value (namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (namespace, key))" - ).run(); - db.prepare( - "CREATE TABLE IF NOT EXISTS combos (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, strategy TEXT, enabled INTEGER DEFAULT 1, target_count INTEGER DEFAULT 0)" - ).run(); - db.close(); -} - -async function withComboEnv(fn: (dataDir: string, dbPath: string) => Promise) { +async function withComboEnv(fn: (dataDir: string) => Promise) { const dataDir = createTempDataDir(); - const dbPath = path.join(dataDir, "storage.sqlite"); process.env.DATA_DIR = dataDir; + // Mock fetch → simulates server offline so withRuntime falls back to DB globalThis.fetch = (async () => { throw new Error("server offline"); }) as typeof fetch; - initComboTable(dbPath); - const originalLog = console.log; console.log = () => {}; try { - await fn(dataDir, dbPath); + await fn(dataDir); } finally { console.log = originalLog; globalThis.fetch = ORIGINAL_FETCH; @@ -56,23 +34,18 @@ async function withComboEnv(fn: (dataDir: string, dbPath: string) => Promise { - await withComboEnv(async (_dataDir, dbPath) => { +test("combo create inserts a new combo via db module", async () => { + await withComboEnv(async () => { const { runComboCreateCommand } = await import("../../bin/cli/commands/combo.mjs"); - const result = await runComboCreateCommand("my-combo", "priority", {}); assert.equal(result, 0); - const db = new Database(dbPath); - const row = db - .prepare("SELECT name, strategy, enabled FROM combos WHERE name = ?") - .get("my-combo") as ComboRow | undefined; - db.close(); - - assert.ok(row); - assert.equal(row.name, "my-combo"); - assert.equal(row.strategy, "priority"); - assert.equal(row.enabled, 1); + // Verify via the same db module + const { getComboByName } = await import("../../src/lib/db/combos.ts"); + const combo = await getComboByName("my-combo"); + assert.ok(combo); + assert.equal(combo.name, "my-combo"); + assert.equal(combo.strategy, "priority"); }); }); @@ -90,8 +63,8 @@ test("combo create fails if combo already exists", async () => { }); }); -test("combo delete removes the row", async () => { - await withComboEnv(async (_dataDir, dbPath) => { +test("combo delete removes the combo", async () => { + await withComboEnv(async () => { const { runComboCreateCommand, runComboDeleteCommand } = await import("../../bin/cli/commands/combo.mjs"); @@ -99,10 +72,9 @@ test("combo delete removes the row", async () => { const result = await runComboDeleteCommand("to-delete", { yes: true }); assert.equal(result, 0); - const db = new Database(dbPath); - const row = db.prepare("SELECT id FROM combos WHERE name = ?").get("to-delete"); - db.close(); - assert.equal(row, undefined); + const { getComboByName } = await import("../../src/lib/db/combos.ts"); + const combo = await getComboByName("to-delete"); + assert.equal(combo, null); }); }); @@ -114,8 +86,8 @@ test("combo list returns 0 with empty combos table", async () => { }); }); -test("combo switch updates key_value settings when server is offline", async () => { - await withComboEnv(async (_dataDir, dbPath) => { +test("combo switch updates active combo when server is offline", async () => { + await withComboEnv(async () => { const { runComboCreateCommand, runComboSwitchCommand } = await import("../../bin/cli/commands/combo.mjs"); @@ -123,13 +95,9 @@ test("combo switch updates key_value settings when server is offline", async () const result = await runComboSwitchCommand("my-switch", {}); assert.equal(result, 0); - const db = new Database(dbPath); - const row = db - .prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'activeCombo'") - .get() as { value: string } | undefined; - db.close(); - - assert.ok(row); - assert.equal(JSON.parse(row.value), "my-switch"); + // Verify active combo written to key_value settings + const { getSettings } = await import("../../src/lib/db/settings.ts"); + const settings = await getSettings(); + assert.equal((settings as Record).activeCombo, "my-switch"); }); }); diff --git a/tests/unit/db-recovery.test.ts b/tests/unit/db-recovery.test.ts new file mode 100644 index 0000000000..0eaa3156d9 --- /dev/null +++ b/tests/unit/db-recovery.test.ts @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; + +async function withRecoveryEnv(fn: (dataDir: string) => Promise) { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-recovery-")); + process.env.DATA_DIR = dataDir; + try { + await fn(dataDir); + } finally { + fs.rmSync(dataDir, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +} + +test("countEncryptedCredentials returns 0 on fresh db", async () => { + await withRecoveryEnv(async () => { + const { countEncryptedCredentials } = await import("../../src/lib/db/recovery.ts"); + const count = countEncryptedCredentials(); + assert.equal(count, 0); + }); +}); + +test("resetEncryptedColumns dry-run returns affected count without mutating", async () => { + await withRecoveryEnv(async () => { + const { resetEncryptedColumns, countEncryptedCredentials } = + await import("../../src/lib/db/recovery.ts"); + + // Insert a fake encrypted row directly using the DB instance + const { getDbInstance } = await import("../../src/lib/db/core.ts"); + const db = getDbInstance(); + const now = new Date().toISOString(); + db.prepare( + "INSERT INTO provider_connections (id, provider, name, api_key, created_at, updated_at) VALUES (?,?,?,?,?,?)" + ).run("test-id", "openai", "test-conn", "enc:v1:fake-encrypted-value", now, now); + + const countBefore = countEncryptedCredentials(); + assert.equal(countBefore, 1); + + const { affected } = resetEncryptedColumns({ dryRun: true }); + assert.equal(affected, 1); + + // Dry run should NOT have mutated + const countAfter = countEncryptedCredentials(); + assert.equal(countAfter, 1); + }); +}); + +test("resetEncryptedColumns force mode nulls encrypted columns", async () => { + await withRecoveryEnv(async () => { + const { resetEncryptedColumns } = await import("../../src/lib/db/recovery.ts"); + const { getDbInstance } = await import("../../src/lib/db/core.ts"); + + const db = getDbInstance(); + const now = new Date().toISOString(); + db.prepare( + "INSERT INTO provider_connections (id, provider, name, api_key, access_token, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" + ).run("rec-id", "anthropic", "rec-conn", "enc:v1:key123", "enc:v1:tok456", now, now); + + const { affected } = resetEncryptedColumns({ dryRun: false }); + assert.ok(affected >= 1); + + const row = db + .prepare("SELECT api_key, access_token FROM provider_connections WHERE id = ?") + .get("rec-id") as { api_key: null; access_token: null } | undefined; + assert.ok(row); + assert.equal(row.api_key, null); + assert.equal(row.access_token, null); + }); +});