mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 06:02:14 +03:00
feat(cli): banir SQLite direto — withRuntime + src/lib/db/* modules (Fase 1.5)
This commit is contained in:
31
.semgrep/rules/cli-no-sqlite.yaml
Normal file
31
.semgrep/rules/cli-no-sqlite.yaml
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
33
src/lib/db/recovery.ts
Normal file
33
src/lib/db/recovery.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getDbInstance } from "./core";
|
||||
|
||||
type DbInstance = ReturnType<typeof getDbInstance>;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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<void>) {
|
||||
async function withComboEnv(fn: (dataDir: string) => Promise<void>) {
|
||||
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<voi
|
||||
}
|
||||
}
|
||||
|
||||
test("combo create inserts a new combo row", async () => {
|
||||
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<string, unknown>).activeCombo, "my-switch");
|
||||
});
|
||||
});
|
||||
|
||||
75
tests/unit/db-recovery.test.ts
Normal file
75
tests/unit/db-recovery.test.ts
Normal file
@@ -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<void>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user