From 79be46f13195e0b4e55c29db4e6ab538239f6ef3 Mon Sep 17 00:00:00 2001 From: Mustafa Date: Sat, 16 May 2026 23:24:01 +0300 Subject: [PATCH 1/2] fix(rqlite): validate node via X-Rqlite-Version header before first query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before executing any SQL, probe the configured endpoint with GET / and verify the X-Rqlite-Version response header is present, as recommended by the rqlite project maintainer. - Add RqliteQueryable.testConnection() — throws with a clear message when the endpoint is unreachable or is not an rqlite node - Call testConnection() lazily inside transaction() on first use; result is cached so subsequent calls pay no overhead - Auth credentials are intentionally omitted from the probe request (the rqlite root path is a public health-check endpoint) - Add rqlite.test.ts covering: transformRawResult, testConnection, transaction integration, and query delegation Closes #59 --- src/drivers/database/rqlite.test.ts | 221 ++++++++++++++++++++++++++++ src/drivers/database/rqlite.ts | 34 +++++ 2 files changed, 255 insertions(+) create mode 100644 src/drivers/database/rqlite.test.ts diff --git a/src/drivers/database/rqlite.test.ts b/src/drivers/database/rqlite.test.ts new file mode 100644 index 00000000..29223d1a --- /dev/null +++ b/src/drivers/database/rqlite.test.ts @@ -0,0 +1,221 @@ +import { RqliteQueryable, transformRawResult } from "./rqlite"; + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +afterEach(() => { + mockFetch.mockReset(); +}); + +// --------------------------------------------------------------------------- +// transformRawResult +// --------------------------------------------------------------------------- + +describe("transformRawResult", () => { + it("maps columns and values into rows", () => { + const result = transformRawResult({ + columns: ["id", "name"], + types: ["integer", "text"], + values: [ + [1, "Alice"], + [2, "Bob"], + ], + rows_affected: 0, + time: 2.5, + }); + + expect(result.headers).toHaveLength(2); + expect(result.rows).toHaveLength(2); + expect(result.rows[0]).toEqual({ id: 1, name: "Alice" }); + expect(result.rows[1]).toEqual({ id: 2, name: "Bob" }); + expect(result.stat.rowsAffected).toBe(0); + expect(result.stat.queryDurationMs).toBe(2.5); + }); + + it("returns empty rows for non-SELECT statements", () => { + const result = transformRawResult({ + rows_affected: 5, + last_insert_id: 99, + }); + + expect(result.rows).toEqual([]); + expect(result.stat.rowsAffected).toBe(5); + expect(result.lastInsertRowid).toBe(99); + }); + + it("renames duplicate column names to avoid key collisions", () => { + const result = transformRawResult({ + columns: ["id", "id"], + types: ["integer", "integer"], + values: [[1, 2]], + }); + + const names = result.headers.map((h) => h.name); + expect(new Set(names).size).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// RqliteQueryable.testConnection +// --------------------------------------------------------------------------- + +describe("RqliteQueryable.testConnection", () => { + it("resolves when the X-Rqlite-Version header is present", async () => { + mockFetch.mockResolvedValueOnce({ + headers: { + get: (h: string) => (h === "X-Rqlite-Version" ? "v8.0.6" : null), + }, + }); + + const q = new RqliteQueryable("http://localhost:4001"); + await expect(q.testConnection()).resolves.toBeUndefined(); + + expect(mockFetch).toHaveBeenCalledWith("http://localhost:4001/", { + method: "GET", + redirect: "manual", + }); + }); + + it("throws a descriptive error when the header is absent", async () => { + mockFetch.mockResolvedValueOnce({ + headers: { get: () => null }, + }); + + const q = new RqliteQueryable("http://localhost:4001"); + await expect(q.testConnection()).rejects.toThrow( + "does not appear to be an rqlite node" + ); + }); + + it("throws when the fetch itself fails (network error)", async () => { + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + + const q = new RqliteQueryable("http://localhost:4001"); + await expect(q.testConnection()).rejects.toThrow( + "Cannot reach rqlite at http://localhost:4001" + ); + }); + + it("does not send auth credentials during the probe", async () => { + mockFetch.mockResolvedValueOnce({ + headers: { + get: (h: string) => (h === "X-Rqlite-Version" ? "v8.0.6" : null), + }, + }); + + const q = new RqliteQueryable("http://localhost:4001", "admin", "secret"); + await q.testConnection(); + + const [, init] = mockFetch.mock.calls[0]; + expect(init?.headers).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// RqliteQueryable.transaction (connection validation integration) +// --------------------------------------------------------------------------- + +describe("RqliteQueryable.transaction", () => { + it("validates the connection before the first transaction", async () => { + mockFetch + .mockResolvedValueOnce({ + // testConnection probe + headers: { + get: (h: string) => (h === "X-Rqlite-Version" ? "v8.0.6" : null), + }, + }) + .mockResolvedValueOnce({ + // actual query + json: async () => ({ + results: [ + { columns: ["n"], types: ["integer"], values: [[42]] }, + ], + }), + }); + + const q = new RqliteQueryable("http://localhost:4001"); + const results = await q.transaction(["SELECT 42 AS n"]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(results[0].rows[0]).toEqual({ n: 42 }); + }); + + it("skips the probe on subsequent transactions (cached)", async () => { + mockFetch + .mockResolvedValueOnce({ + headers: { + get: (h: string) => (h === "X-Rqlite-Version" ? "v8.0.6" : null), + }, + }) + .mockResolvedValueOnce({ + json: async () => ({ results: [{ rows_affected: 0 }] }), + }) + .mockResolvedValueOnce({ + json: async () => ({ results: [{ rows_affected: 0 }] }), + }); + + const q = new RqliteQueryable("http://localhost:4001"); + await q.transaction(["SELECT 1"]); + await q.transaction(["SELECT 2"]); + + // 1 probe + 2 query calls = 3 total (not 4) + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it("propagates a failed probe as an error before any query runs", async () => { + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + + const q = new RqliteQueryable("http://localhost:4001"); + await expect(q.transaction(["SELECT 1"])).rejects.toThrow( + "Cannot reach rqlite at http://localhost:4001" + ); + + // The query fetch must NOT have been called + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("sends Basic auth on query requests when credentials are provided", async () => { + mockFetch + .mockResolvedValueOnce({ + headers: { + get: (h: string) => (h === "X-Rqlite-Version" ? "v8.0.6" : null), + }, + }) + .mockResolvedValueOnce({ + json: async () => ({ results: [{ rows_affected: 0 }] }), + }); + + const q = new RqliteQueryable("http://localhost:4001", "admin", "s3cr3t"); + await q.transaction(["DELETE FROM t WHERE 0=1"]); + + const [, queryInit] = mockFetch.mock.calls[1]; + expect((queryInit?.headers as Record)["Authorization"]).toBe( + "Basic " + btoa("admin:s3cr3t") + ); + }); +}); + +// --------------------------------------------------------------------------- +// RqliteQueryable.query (delegates to transaction) +// --------------------------------------------------------------------------- + +describe("RqliteQueryable.query", () => { + it("validates the connection and returns the first result set", async () => { + mockFetch + .mockResolvedValueOnce({ + headers: { + get: (h: string) => (h === "X-Rqlite-Version" ? "v8.0.6" : null), + }, + }) + .mockResolvedValueOnce({ + json: async () => ({ + results: [{ columns: ["val"], types: ["text"], values: [["hello"]] }], + }), + }); + + const q = new RqliteQueryable("http://localhost:4001"); + const result = await q.query("SELECT 'hello' AS val"); + + expect(result.rows[0]).toEqual({ val: "hello" }); + }); +}); diff --git a/src/drivers/database/rqlite.ts b/src/drivers/database/rqlite.ts index 410bf098..0d0a4413 100644 --- a/src/drivers/database/rqlite.ts +++ b/src/drivers/database/rqlite.ts @@ -68,13 +68,47 @@ export function transformRawResult(raw: RqliteResult): DatabaseResultSet { } export class RqliteQueryable implements QueryableBaseDriver { + private connectionVerified = false; + constructor( protected endpoint: string, protected username?: string, protected password?: string ) {} + /** + * Probes the rqlite node by requesting the root path and verifying the + * X-Rqlite-Version response header, as recommended by the rqlite project: + * https://github.com/rqlite/rqlite/blob/master/cmd/rqlite/main.go#L101 + */ + async testConnection(): Promise { + let response: Response; + + try { + response = await fetch(this.endpoint + "/", { + method: "GET", + redirect: "manual", + }); + } catch (err) { + throw new Error( + `Cannot reach rqlite at ${this.endpoint}: ${(err as Error).message}` + ); + } + + if (!response.headers.get("X-Rqlite-Version")) { + throw new Error( + `The server at ${this.endpoint} does not appear to be an rqlite node ` + + `(X-Rqlite-Version header missing). Verify the URL and port.` + ); + } + } + async transaction(stmts: string[]): Promise { + if (!this.connectionVerified) { + await this.testConnection(); + this.connectionVerified = true; + } + let headers: HeadersInit = { "Content-Type": "application/json", }; From 7c634757ad790005d68247a02de1ea811f22efda Mon Sep 17 00:00:00 2001 From: Mustafa Date: Sun, 17 May 2026 00:02:09 +0300 Subject: [PATCH 2/2] test(rqlite): remove duplicate-column test (dedup not in scope for #59) --- src/drivers/database/rqlite.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/drivers/database/rqlite.test.ts b/src/drivers/database/rqlite.test.ts index 29223d1a..38e26f50 100644 --- a/src/drivers/database/rqlite.test.ts +++ b/src/drivers/database/rqlite.test.ts @@ -43,16 +43,6 @@ describe("transformRawResult", () => { expect(result.lastInsertRowid).toBe(99); }); - it("renames duplicate column names to avoid key collisions", () => { - const result = transformRawResult({ - columns: ["id", "id"], - types: ["integer", "integer"], - values: [[1, 2]], - }); - - const names = result.headers.map((h) => h.name); - expect(new Set(names).size).toBe(2); - }); }); // ---------------------------------------------------------------------------