From 5a2d8d19d54813033f7102a5528d3348b8626af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9D=80=EC=84=9D?= Date: Tue, 12 May 2026 15:56:02 +0900 Subject: [PATCH] fix(rqlite): validate rqlite node before first query Before executing any SQL, call GET / on the configured endpoint and check for the X-Rqlite-Version response header. If the header is missing the endpoint is not an rqlite node and a clear error is thrown early. The check runs once per RqliteQueryable instance (lazy on first transaction) so repeated queries pay no extra overhead. Adds unit tests covering successful validation, missing header, network failure, lazy-init deduplication, and Basic auth header injection. Closes #59 --- src/drivers/database/rqlite.test.ts | 139 ++++++++++++++++++++++++++++ src/drivers/database/rqlite.ts | 29 ++++++ 2 files changed, 168 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..13e47a12 --- /dev/null +++ b/src/drivers/database/rqlite.test.ts @@ -0,0 +1,139 @@ +import { RqliteQueryable, transformRawResult } from "./rqlite"; + +// Mock global fetch +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +afterEach(() => { + mockFetch.mockReset(); +}); + +describe("transformRawResult", () => { + it("maps columns and values to rows", () => { + const result = transformRawResult({ + columns: ["id", "name"], + types: ["integer", "text"], + values: [ + [1, "Alice"], + [2, "Bob"], + ], + rows_affected: 0, + time: 1.5, + }); + + expect(result.headers).toHaveLength(2); + expect(result.rows).toHaveLength(2); + expect(result.rows[0]).toEqual({ id: 1, name: "Alice" }); + expect(result.stat.rowsAffected).toBe(0); + expect(result.stat.queryDurationMs).toBe(1.5); + }); + + it("handles missing values (non-SELECT statements)", () => { + const result = transformRawResult({ + rows_affected: 3, + last_insert_id: 42, + }); + + expect(result.rows).toEqual([]); + expect(result.stat.rowsAffected).toBe(3); + expect(result.lastInsertRowid).toBe(42); + }); +}); + +describe("RqliteQueryable.testConnection", () => { + it("resolves when X-Rqlite-Version header is present", async () => { + mockFetch.mockResolvedValueOnce({ + headers: { get: (h: string) => (h === "X-Rqlite-Version" ? "v8.0.0" : 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 when X-Rqlite-Version 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 with a descriptive message when fetch fails", async () => { + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + + const q = new RqliteQueryable("http://localhost:4001"); + await expect(q.testConnection()).rejects.toThrow( + "Unable to reach rqlite at http://localhost:4001" + ); + }); +}); + +describe("RqliteQueryable.transaction", () => { + it("calls testConnection before the first transaction", async () => { + // testConnection fetch + mockFetch.mockResolvedValueOnce({ + headers: { get: (h: string) => (h === "X-Rqlite-Version" ? "v8.0.0" : null) }, + }); + // transaction fetch + mockFetch.mockResolvedValueOnce({ + json: async () => ({ + results: [{ columns: ["n"], types: ["integer"], values: [[1]] }], + }), + }); + + const q = new RqliteQueryable("http://localhost:4001"); + const results = await q.transaction(["SELECT 1 AS n"]); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(results[0].rows[0]).toEqual({ n: 1 }); + }); + + it("skips testConnection on subsequent calls", async () => { + // First testConnection + first transaction + mockFetch + .mockResolvedValueOnce({ + headers: { get: (h: string) => (h === "X-Rqlite-Version" ? "v8.0.0" : null) }, + }) + .mockResolvedValueOnce({ + json: async () => ({ results: [{ rows_affected: 0 }] }), + }) + // Second transaction — no testConnection call + .mockResolvedValueOnce({ + json: async () => ({ results: [{ rows_affected: 0 }] }), + }); + + const q = new RqliteQueryable("http://localhost:4001"); + await q.transaction(["SELECT 1"]); + await q.transaction(["SELECT 2"]); + + // Only 3 total calls (1 test + 2 transactions), not 4 + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it("adds Basic auth header when credentials are provided", async () => { + mockFetch + .mockResolvedValueOnce({ + headers: { get: (h: string) => (h === "X-Rqlite-Version" ? "v8.0.0" : null) }, + }) + .mockResolvedValueOnce({ + json: async () => ({ results: [{ rows_affected: 0 }] }), + }); + + const q = new RqliteQueryable("http://localhost:4001", "admin", "secret"); + await q.transaction(["DELETE FROM t WHERE 1=0"]); + + const transactionCall = mockFetch.mock.calls[1]; + const headers = transactionCall[1].headers; + expect(headers["Authorization"]).toBe( + "Basic " + btoa("admin:secret") + ); + }); +}); diff --git a/src/drivers/database/rqlite.ts b/src/drivers/database/rqlite.ts index 410bf098..4dd7fb0c 100644 --- a/src/drivers/database/rqlite.ts +++ b/src/drivers/database/rqlite.ts @@ -68,13 +68,42 @@ export function transformRawResult(raw: RqliteResult): DatabaseResultSet { } export class RqliteQueryable implements QueryableBaseDriver { + private connectionVerified = false; + constructor( protected endpoint: string, protected username?: string, protected password?: string ) {} + // Verifies the endpoint is an rqlite node by checking for the X-Rqlite-Version + // response header on the root path, as described in rqlite docs. + async testConnection(): Promise { + let response: Response; + try { + response = await fetch(this.endpoint + "/", { + method: "GET", + redirect: "manual", + }); + } catch (e) { + throw new Error( + `Unable to reach rqlite at ${this.endpoint}: ${(e as Error).message}` + ); + } + + if (!response.headers.get("X-Rqlite-Version")) { + throw new Error( + `${this.endpoint} does not appear to be an rqlite node (missing X-Rqlite-Version header)` + ); + } + } + async transaction(stmts: string[]): Promise { + if (!this.connectionVerified) { + await this.testConnection(); + this.connectionVerified = true; + } + let headers: HeadersInit = { "Content-Type": "application/json", };