Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bridge/__tests__/connectionBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@ describe("ConnectionBuilder", () => {
expect(config.readonly).toBe(true);
});

test("should reject Windows drive roots for SQLite", () => {
const dbInput = { database: "D:/" };
expect(() => ConnectionBuilder.buildSQLiteConnection(dbInput)).toThrow(
'Invalid SQLite path "D:/"'
);
});

test("buildConnection with DBType.SQLITE should return SQLiteConfig", () => {
const dbInput = { database: "/tmp/test.db" };
const config = ConnectionBuilder.buildConnection(dbInput, null, DBType.SQLITE) as SQLiteConfig;
Expand Down
7 changes: 7 additions & 0 deletions bridge/__tests__/connectors/sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ describe("SQLite Connector", () => {
expect(connection.status).toBe("disconnected");
expect(connection.message).toBeDefined();
});

test("Should fail cleanly when SQLite path is a directory", async () => {
const connection = await sqliteConnector.testConnection({ path: tmpDir });
expect(connection.ok).toBe(false);
expect(connection.status).toBe("disconnected");
expect(connection.message).toContain("directory");
});
});

// ===============================
Expand Down
44 changes: 43 additions & 1 deletion bridge/__tests__/databaseService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { afterAll, describe, expect, test } from "@jest/globals";
import { DatabaseService } from "../src/services/databaseService";
import fs from "fs/promises";
import fsSync from "fs";
import os from "os";
import path from "path";

const TEST_RELWAVE_HOME = path.join(os.tmpdir(), `database-service-test-${Date.now()}`);
process.env.RELWAVE_HOME = TEST_RELWAVE_HOME;

// eslint-disable-next-line @typescript-eslint/no-var-requires
const { DatabaseService } = require("../src/services/databaseService");

const mockInput = {
name: "TestDB",
Expand Down Expand Up @@ -46,6 +55,10 @@ describe("Database Service Method", () => {
} catch (e) {
// Ignore errors during final cleanup
}

if (fsSync.existsSync(TEST_RELWAVE_HOME)) {
await fs.rm(TEST_RELWAVE_HOME, { recursive: true, force: true });
}
});

// Test Case 1: All required fields provided
Expand All @@ -60,6 +73,35 @@ describe("Database Service Method", () => {
expect(result.name).toBe(payload.name);
});

test("should normalize SQLite database paths when adding a database", async () => {
const payload = {
name: "SQLiteTestDB",
host: "",
port: 0,
user: "",
database: "sqlite:///C:/Users/test/relwave.db",
type: "sqlite",
};

const result = await dbService.addDatabase(payload);
createdDbIds.push(result.id);

expect(result.database).toBe("C:/Users/test/relwave.db");
});

test("should reject Windows drive roots for SQLite databases", async () => {
const payload = {
name: "SQLiteDriveRootDB",
host: "",
port: 0,
user: "",
database: "D:/",
type: "sqlite",
};

await expect(dbService.addDatabase(payload)).rejects.toThrow('Invalid SQLite path "D:/"');
});

// Test Case 2: Missing required field 'host'

test("should throw error when required field 'host' is missing", async () => {
Expand Down
54 changes: 54 additions & 0 deletions bridge/__tests__/dbStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ const mockDBPayload = {
password: "testpassword123",
};

const mockSQLitePayload = {
name: "SQLiteDB",
host: "",
port: 0,
user: "",
database: "sqlite:///C:/Users/test/example.db",
type: "sqlite",
};

describe("DbStore Cache Tests", () => {
let dbStore: DbStore;

Expand Down Expand Up @@ -91,6 +100,15 @@ describe("DbStore Cache Tests", () => {
const deleted = await dbStore.getDB(result.id);
expect(deleted).toBeUndefined();
});

test("should normalize stored SQLite database paths", async () => {
const result = await dbStore.addDB(mockSQLitePayload);

expect(result.database).toBe("C:/Users/test/example.db");

const retrieved = await dbStore.getDB(result.id);
expect(retrieved?.database).toBe("C:/Users/test/example.db");
});
});

describe("Password Encryption", () => {
Expand Down Expand Up @@ -331,6 +349,42 @@ describe("DbStore Cache Tests", () => {
expect(manualStore.isReady()).toBe(true);
expect(manualStore.getCacheStats().configCached).toBe(true);
});

test("should normalize SQLite paths while preloading existing config data", async () => {
const existingConfig = {
version: 1,
databases: [
{
id: "sqlite-1",
name: "SQLite Existing",
host: "",
port: 0,
user: "",
database: "file:///C:/Users/test/preloaded.db",
type: "sqlite",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
};

await fs.writeFile(TEST_CONFIG_FILE, JSON.stringify(existingConfig, null, 2), "utf-8");

const preloadStore = new DbStore(
TEST_CONFIG_FOLDER,
TEST_CONFIG_FILE,
TEST_CREDENTIALS_FILE,
NORMAL_CACHE_TTL,
true
);
await preloadStore.waitUntilReady();

const db = await preloadStore.getDB("sqlite-1");
expect(db?.database).toBe("C:/Users/test/preloaded.db");

const persisted = JSON.parse(await fs.readFile(TEST_CONFIG_FILE, "utf-8"));
expect(persisted.databases[0].database).toBe("C:/Users/test/preloaded.db");
});
});

describe("Cache Performance", () => {
Expand Down
72 changes: 27 additions & 45 deletions bridge/src/connectors/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import fs from "fs";
import os from "os";
import path from "path";
import { ensureDir, getMigrationsDir } from "../utils/config";
import { isWindowsDriveRootPath, normalizeSQLitePath } from "../utils/sqlitePath";
import {
CacheEntry,
CACHE_TTL,
Expand Down Expand Up @@ -257,37 +258,35 @@ export const sqliteCache = new SQLiteCacheManager();

let cachedNativeBindingPath: string | null | undefined = undefined;

function normalizeSQLitePath(rawPath: string): string {
const trimmed = rawPath.trim();
if (!trimmed) {
return trimmed;
}
function validateSQLitePath(
rawPath: string,
options: { requireExistingFile?: boolean } = {}
): string {
const dbPath = normalizeSQLitePath(rawPath);

if (/^\/[A-Za-z]:\//.test(trimmed)) {
return trimmed.slice(1);
if (!dbPath || !dbPath.trim()) {
throw new Error(`Invalid SQLite path: path is empty or missing. Received: ${JSON.stringify(rawPath)}`);
}

if (/^(?:file|sqlite):\/\//i.test(trimmed)) {
try {
const parsed = new URL(trimmed);
const hostname = parsed.hostname || "";
const pathname = parsed.pathname || "";

if (hostname && /^[A-Za-z]$/.test(hostname) && pathname.startsWith("/")) {
return `${hostname}:${pathname}`;
}

if (!hostname && /^\/[A-Za-z]:\//.test(pathname)) {
return pathname.slice(1);
}
if (isWindowsDriveRootPath(dbPath)) {
throw new Error(
`Invalid SQLite path "${dbPath}" - it points to a Windows drive root, not a database file.`
);
}

return decodeURIComponent(`${hostname}${pathname}`);
} catch {
return trimmed;
if (fs.existsSync(dbPath)) {
const stat = fs.statSync(dbPath);
if (stat.isDirectory()) {
throw new Error(`Invalid SQLite path "${dbPath}" - it points to a directory, not a database file.`);
}
return dbPath;
}

return trimmed;
if (options.requireExistingFile) {
throw new Error(`Database file does not exist at path: ${dbPath}`);
}

return dbPath;
}

function findExistingBindingPath(candidates: Iterable<string>): string | undefined {
Expand Down Expand Up @@ -403,7 +402,9 @@ function resolvePkgNativeBindingPath(): string | undefined {

function openDB(cfg: SQLiteConfig, extraOptions: Database.Options = {}): Database.Database {
const nativeBinding = resolvePkgNativeBindingPath();
const dbPath = normalizeSQLitePath(cfg.path);
const dbPath = validateSQLitePath(cfg.path, {
requireExistingFile: Boolean(extraOptions.fileMustExist),
});
const options: Database.Options = {
readonly: cfg.readonly ?? false,
...extraOptions,
Expand All @@ -427,34 +428,15 @@ function quoteIdent(name: string): string {
/** Test connection to SQLite database (checks if file is accessible) */
export async function testConnection(cfg: SQLiteConfig): Promise<{ ok: boolean; message?: string; status: 'connected' | 'disconnected' }> {
try {
const dbPath = normalizeSQLitePath(cfg.path);
const dbPath = validateSQLitePath(cfg.path, { requireExistingFile: true });

// Validate the path field exists
if (!dbPath || typeof dbPath !== "string" || !dbPath.trim()) {
return {
ok: false,
status: "disconnected",
message: `Invalid SQLite path: path is empty or missing. Received: ${JSON.stringify(cfg)}`,
};
}

// Detect truncated Windows drive-letter-only paths (e.g. "C:" or "D:")
if (/^[A-Za-z]:$/.test(dbPath)) {
return {
ok: false,
status: "disconnected",
message: `Invalid SQLite path "${cfg.path}" — looks like a truncated Windows drive letter. Please re-add the database with the full file path.`,
};
}

// Ensure the database file exists to avoid implicitly creating a new empty DB
if (!fs.existsSync(dbPath)) {
return {
ok: false,
status: "disconnected",
message: `Database file does not exist at path: ${dbPath}`,
};
}
// Use fileMustExist so better-sqlite3 will not create a new file during connection test
const db = openDB({ ...cfg, path: dbPath }, {
fileMustExist: true,
Expand Down
43 changes: 6 additions & 37 deletions bridge/src/services/connectionBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,6 @@
import { DBType, DatabaseConfig } from "../types";
import { SQLiteConfig } from "../types/sqlite";

function normalizeSQLitePath(rawPath: unknown): string {
if (typeof rawPath !== "string") {
return "";
}

const trimmed = rawPath.trim();
if (!trimmed) {
return "";
}

if (/^\/[A-Za-z]:\//.test(trimmed)) {
return trimmed.slice(1);
}

if (/^(?:file|sqlite):\/\//i.test(trimmed)) {
try {
const parsed = new URL(trimmed);
const hostname = parsed.hostname || "";
const pathname = parsed.pathname || "";

if (hostname && /^[A-Za-z]$/.test(hostname) && pathname.startsWith("/")) {
return `${hostname}:${pathname}`;
}

if (!hostname && /^\/[A-Za-z]:\//.test(pathname)) {
return pathname.slice(1);
}

return decodeURIComponent(`${hostname}${pathname}`);
} catch {
return trimmed;
}
}

return trimmed;
}
import { isWindowsDriveRootPath, normalizeSQLitePath } from "../utils/sqlitePath";

export class ConnectionBuilder {
static buildConnection(
Expand All @@ -52,6 +16,11 @@ export class ConnectionBuilder {
`Got database=${JSON.stringify(db.database)}, path=${JSON.stringify(db.path)}`
);
}
if (isWindowsDriveRootPath(dbPath)) {
throw new Error(
`Invalid SQLite path "${dbPath}" — it points to a Windows drive root, not a database file.`
);
}
return {
path: dbPath,
readonly: db.readonly ?? false,
Expand Down
22 changes: 22 additions & 0 deletions bridge/src/services/databaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,33 @@ export class DatabaseService {
for (const field of required) {
if (!payload[field]) throw new Error(`Missing required field: ${field}`);
}
if (isSQLite) {
const sqliteConfig = ConnectionBuilder.buildSQLiteConnection(payload);
payload = {
...payload,
database: sqliteConfig.path,
};
}
return dbStoreInstance.addDB(payload as Parameters<typeof dbStoreInstance.addDB>[0]);
}

async updateDatabase(id: string, payload: Record<string, unknown>) {
if (!id) throw new Error("Missing id");
const isSQLite = (payload.type as string | undefined)?.toLowerCase().includes("sqlite");
if (isSQLite || typeof payload.database === "string") {
const current = await dbStoreInstance.getDB(id);
const currentIsSQLite = (current?.type as string | undefined)?.toLowerCase().includes("sqlite");
if (currentIsSQLite || isSQLite) {
const sqliteConfig = ConnectionBuilder.buildSQLiteConnection({
...current,
...payload,
});
payload = {
...payload,
database: sqliteConfig.path,
};
}
}
connectionPool.invalidate(id); // evict stale cached config
return dbStoreInstance.updateDB(id, payload as Parameters<typeof dbStoreInstance.updateDB>[1]);
}
Expand Down
Loading
Loading