Files
ekklesia 859675c3c7
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
feat: add module database ABI v1 adapters
2026-10-07 16:44:28 -05:00

96 lines
3.7 KiB
TypeScript

import {
ModuleDatabaseError,
type ModuleDatabaseExecutor,
type ModuleDatabaseParameter,
} from "@ekklesia/contracts";
import { describe, expect, it } from "vitest";
import { findEkklesiaDatabaseError, sql } from "../src/index.js";
describe("the raw Module Database ABI v1 SQL tag", () => {
it("keeps static SQL unchanged when there are no interpolations", () => {
expect(sql`SELECT id FROM calendar_events ORDER BY id`).toEqual({
text: "SELECT id FROM calendar_events ORDER BY id",
values: [],
});
});
it("turns every interpolation into an ordered PostgreSQL parameter", () => {
const id = "event-1";
const active = true;
expect(sql`SELECT * FROM calendar_events WHERE id = ${id} AND active = ${active}`).toEqual({
text: "SELECT * FROM calendar_events WHERE id = $1 AND active = $2",
values: [id, active],
});
});
it("gives repeated values separate positional parameters", () => {
const value = "same";
expect(sql`SELECT ${value}, ${value}, ${value}`).toEqual({
text: "SELECT $1, $2, $3",
values: [value, value, value],
});
});
it("keeps SQL-injection-sensitive strings bound instead of joining them into SQL", () => {
const attack = "x'; DROP TABLE calendar_events; --";
const query = sql`SELECT * FROM calendar_events WHERE title = ${attack}`;
expect(query.text).toBe("SELECT * FROM calendar_events WHERE title = $1");
expect(query.text).not.toContain(attack);
expect(query.values).toEqual([attack]);
});
it("preserves bigint, byte arrays, and PostgreSQL arrays as ABI values", () => {
const bytes = new Uint8Array([0, 127, 255]);
const identifiers = [1n, 2n, null] as const;
const query = sql`SELECT ${9_007_199_254_740_993n}, ${bytes}, ${identifiers}`;
expect(query.values).toEqual([9_007_199_254_740_993n, bytes, identifiers]);
expect(query.values[1]).toBe(bytes);
expect(query.values[2]).toBe(identifiers);
});
it("does not stringify unsupported values before the ABI executor rejects them", async () => {
const invalid = new Date("2026-01-02T03:04:05.000Z");
const query = sql`SELECT ${invalid as unknown as ModuleDatabaseParameter}`;
expect(query.values[0]).toBe(invalid);
const executor = {
abiVersion: 1,
async query(request: { readonly values?: readonly ModuleDatabaseParameter[] }) {
if (request.values?.some((value) => value instanceof Date)) {
throw new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
}
throw new Error("Expected the fake ABI validator to reject the value.");
},
async transaction() {
throw new Error("not used");
},
} as unknown as ModuleDatabaseExecutor;
await expect(executor.query(query)).rejects.toMatchObject({
code: "MODULE_DATABASE_PARAMETER_INVALID",
name: "ModuleDatabaseError",
});
});
});
describe("findEkklesiaDatabaseError", () => {
it("finds a stable ABI error through wrappers without parsing messages", () => {
const databaseError = new ModuleDatabaseError("MODULE_DATABASE_QUERY_FAILED", {
sqlState: "23505",
});
const wrapped = new Error("wrapper", { cause: new Error("second", { cause: databaseError }) });
expect(findEkklesiaDatabaseError(wrapped)).toBe(databaseError);
expect(findEkklesiaDatabaseError(wrapped)).toMatchObject({
code: "MODULE_DATABASE_QUERY_FAILED",
sqlState: "23505",
});
});
it("terminates safely for cause cycles and unrelated errors", () => {
const cyclic = new Error("cyclic");
cyclic.cause = cyclic;
expect(findEkklesiaDatabaseError(cyclic)).toBeUndefined();
expect(findEkklesiaDatabaseError("not an error")).toBeUndefined();
});
});