Skip to content

Commit da1c57a

Browse files
committed
test(drizzle): v3 DB integration suite (provisioning, round-trip, domain matrix)
DB-gated suites (describe.skipIf without DATABASE_URL): advisory-lock installer, text_eq round-trip, and the text domain matrix with an in-memory oracle and negative assertions (domain CHECK, blocker RAISE, wrong-domain).
1 parent dad35e3 commit da1c57a

5 files changed

Lines changed: 459 additions & 0 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import 'dotenv/config'
2+
import { protect } from '@cipherstash/protect'
3+
import { sql as dsql } from 'drizzle-orm'
4+
import { pgTable } from 'drizzle-orm/pg-core'
5+
import { drizzle } from 'drizzle-orm/postgres-js'
6+
import postgres from 'postgres'
7+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
8+
import {
9+
createProtectOperators,
10+
eqlV3Type,
11+
extractProtectSchema,
12+
} from '../src/pg/v3/index'
13+
import {
14+
oracleAscLabels,
15+
oracleContains,
16+
oracleEq,
17+
oracleLt,
18+
oracleNe,
19+
v3SeedData,
20+
} from './fixtures/eql-v3-seed-data'
21+
import { installEqlV3 } from './v3/helpers/install-v3'
22+
23+
// DB-gated suite: skipped (not failed) without DATABASE_URL — see provisioning.test.ts.
24+
const HAS_DB = !!process.env.DATABASE_URL
25+
26+
const SKIP_ORDER_BY = true // shared CI DB, mirrors drizzle.test.ts:61
27+
const TABLE = `v3_matrix_${Date.now()}`
28+
29+
const table = pgTable(TABLE, {
30+
t_storage: eqlV3Type<string>('t_storage', { dataType: 'text' }),
31+
t_eq: eqlV3Type<string>('t_eq', { dataType: 'text', index: 'equality' }),
32+
t_match: eqlV3Type<string>('t_match', {
33+
dataType: 'text',
34+
index: 'freeTextSearch',
35+
}),
36+
t_ord: eqlV3Type<string>('t_ord', {
37+
dataType: 'text',
38+
index: 'orderAndRange',
39+
}),
40+
})
41+
const schema = extractProtectSchema(table)
42+
43+
let sql: ReturnType<typeof postgres>
44+
let db: ReturnType<typeof drizzle>
45+
let pc: Awaited<ReturnType<typeof protect>>
46+
let ops: ReturnType<typeof createProtectOperators>
47+
48+
// biome-ignore lint/suspicious/noExplicitAny: test unwrap
49+
function unwrap(r: any) {
50+
if (r.failure) {
51+
throw new Error(
52+
`[protect] ${r.failure.message ?? JSON.stringify(r.failure)}`,
53+
)
54+
}
55+
return r.data
56+
}
57+
58+
beforeAll(async () => {
59+
if (!HAS_DB) return
60+
// { prepare: false } is required for the pooled CI DB (PgBouncer transaction
61+
// mode) — mirrors packages/protect/__tests__/searchable-json-pg.test.ts:12.
62+
sql = postgres(process.env.DATABASE_URL as string, { prepare: false })
63+
await installEqlV3(sql)
64+
await sql.unsafe(`CREATE TABLE IF NOT EXISTS "${TABLE}" (
65+
t_storage eql_v3.text,
66+
t_eq eql_v3.text_eq,
67+
t_match eql_v3.text_match,
68+
t_ord eql_v3.text_ord
69+
)`)
70+
await sql.unsafe(
71+
`CREATE INDEX IF NOT EXISTS "${TABLE}_eq_idx" ON "${TABLE}" (eql_v3.eq_term(t_eq))`,
72+
)
73+
await sql.unsafe(
74+
`CREATE INDEX IF NOT EXISTS "${TABLE}_match_idx" ON "${TABLE}" USING gin (eql_v3.match_term(t_match))`,
75+
)
76+
await sql.unsafe(
77+
`CREATE INDEX IF NOT EXISTS "${TABLE}_ord_idx" ON "${TABLE}" (eql_v3.ord_term(t_ord))`,
78+
)
79+
80+
db = drizzle({ client: sql })
81+
pc = await protect({ schemas: [schema] })
82+
ops = createProtectOperators(pc)
83+
84+
const models = v3SeedData.map((r) => ({
85+
t_storage: r.label,
86+
t_eq: r.label,
87+
t_match: r.label,
88+
t_ord: r.label,
89+
}))
90+
const enc = unwrap(await pc.bulkEncryptModels(models, schema))
91+
await db.insert(table).values(enc as never[])
92+
}, 180000)
93+
94+
afterAll(async () => {
95+
await sql?.unsafe(`DROP TABLE IF EXISTS "${TABLE}"`)
96+
await sql?.end()
97+
})
98+
99+
async function decryptLabels(
100+
rows: unknown[],
101+
col: 't_storage' | 't_eq' | 't_match' | 't_ord',
102+
) {
103+
const dec = unwrap(await pc.bulkDecryptModels(rows as never[]))
104+
// biome-ignore lint/suspicious/noExplicitAny: dynamic col access
105+
return dec.map((d: any) => d[col]).sort()
106+
}
107+
108+
describe.skipIf(!HAS_DB)('EQL v3 text domain matrix', () => {
109+
it('t_storage: insert + decrypt round-trip', async () => {
110+
const rows = await db.select().from(table)
111+
expect(rows.length).toBe(v3SeedData.length)
112+
const labels = await decryptLabels(rows, 't_storage')
113+
expect(labels).toEqual(v3SeedData.map((r) => r.label).sort())
114+
})
115+
116+
it('t_storage: NULL round-trips through the adapter codec (not just raw IS NULL)', async () => {
117+
await sql.unsafe(`INSERT INTO "${TABLE}" (t_storage) VALUES (NULL)`)
118+
// Read the NULL row back THROUGH the Drizzle typed column so v3FromDriver's
119+
// null branch is exercised, then decrypt through the codec — proving the
120+
// adapter maps a DB NULL to a JS null end-to-end, not just that a NULL exists.
121+
const nullRows = await db
122+
.select()
123+
.from(table)
124+
.where(dsql`${table.t_storage} IS NULL`)
125+
expect(nullRows.length).toBeGreaterThanOrEqual(1)
126+
// biome-ignore lint/suspicious/noExplicitAny: reading the typed column value
127+
expect((nullRows[0] as any).t_storage).toBeNull()
128+
const dec = unwrap(await pc.bulkDecryptModels(nullRows as never[]))
129+
expect(dec[0].t_storage).toBeNull()
130+
})
131+
132+
it('t_eq: = returns exactly the matching row(s)', async () => {
133+
const rows = await db
134+
.select()
135+
.from(table)
136+
.where(await ops.eq(table.t_eq, 'banana'))
137+
expect(await decryptLabels(rows, 't_eq')).toEqual(
138+
oracleEq('banana').map((r) => r.label),
139+
)
140+
})
141+
142+
it('t_eq: <> returns the exact complement', async () => {
143+
const rows = await db
144+
.select()
145+
.from(table)
146+
.where(await ops.ne(table.t_eq, 'banana'))
147+
expect(await decryptLabels(rows, 't_eq')).toEqual(
148+
oracleNe('banana')
149+
.map((r) => r.label)
150+
.sort(),
151+
)
152+
})
153+
154+
it('t_eq: HMAC determinism — duplicate plaintext, = returns both', async () => {
155+
const enc = unwrap(await pc.bulkEncryptModels([{ t_eq: 'banana' }], schema))
156+
await db.insert(table).values(enc[0] as never)
157+
const rows = await db
158+
.select()
159+
.from(table)
160+
.where(await ops.eq(table.t_eq, 'banana'))
161+
expect(rows.length).toBe(2)
162+
})
163+
164+
it('t_match: containment returns matches and excludes non-matching', async () => {
165+
const rows = await db
166+
.select()
167+
.from(table)
168+
.where(await ops.ilike(table.t_match, 'aard'))
169+
const got = await decryptLabels(rows, 't_match')
170+
expect(got).toEqual(
171+
oracleContains('aard')
172+
.map((r) => r.label)
173+
.sort(),
174+
)
175+
expect(got).not.toContain('banana')
176+
})
177+
178+
it('t_ord: < returns the lexicographic prefix set', async () => {
179+
const rows = await db
180+
.select()
181+
.from(table)
182+
.where(await ops.lt(table.t_ord, 'cherry'))
183+
expect(await decryptLabels(rows, 't_ord')).toEqual(
184+
oracleLt('cherry')
185+
.map((r) => r.label)
186+
.sort(),
187+
)
188+
})
189+
190+
const ordByIt = SKIP_ORDER_BY ? it.skip : it
191+
ordByIt('t_ord: ORDER BY returns the exact decrypted sequence', async () => {
192+
const rows = await db.select().from(table).orderBy(ops.asc(table.t_ord))
193+
const dec = unwrap(await pc.bulkDecryptModels(rows as never[]))
194+
// biome-ignore lint/suspicious/noExplicitAny: dynamic col
195+
expect(dec.map((d: any) => d.t_ord)).toEqual(oracleAscLabels())
196+
})
197+
198+
// §7 min/max coverage. Skipped under the same shared-CI ORDER BY constraint
199+
// (MIN/MAX over an encrypted ord domain relies on the same ORE ordering the
200+
// CI DB declines). Present as a stub so the gap is visible, not omitted.
201+
ordByIt('t_ord: MIN()/MAX() return the lexicographic extremes', async () => {
202+
const rows = await sql.unsafe(
203+
`SELECT eql_v3.min(t_ord) AS lo, eql_v3.max(t_ord) AS hi FROM "${TABLE}"`,
204+
)
205+
expect(rows.length).toBe(1)
206+
})
207+
})
208+
209+
describe.skipIf(!HAS_DB)('v3 negative assertions', () => {
210+
it('CHECK rejects a match payload (bf, no hm) inserted into text_eq', async () => {
211+
// A match payload carries {c,i,v,bf} but no hm; text_eq_check requires hm, so
212+
// coercing it into text_eq fails with the domain CHECK (SQLSTATE 23514).
213+
const matchEnc = unwrap(
214+
await pc.bulkEncryptModels([{ t_match: 'banana' }], schema),
215+
)
216+
const badPayload = JSON.stringify(
217+
(matchEnc[0] as Record<string, unknown>).t_match,
218+
)
219+
await expect(
220+
sql.unsafe(
221+
`INSERT INTO "${TABLE}" (t_eq) VALUES ('${badPayload}'::jsonb::eql_v3.text_eq)`,
222+
),
223+
).rejects.toThrow(/text_eq_check|23514/i)
224+
})
225+
226+
it('blocker RAISEs for an unsupported ordering operator on text_eq', async () => {
227+
// text_eq is equality-only: eql_v3.lt(text_eq, …) is a blocker that RAISEs
228+
// 'operator < is not supported for eql_v3.text_eq' — NOT a bare Postgres 42883
229+
// "operator does not exist". The operator EXISTS and binds the blocker fn.
230+
await expect(
231+
sql.unsafe(`SELECT * FROM "${TABLE}" WHERE t_eq < t_eq`),
232+
).rejects.toThrow(/not supported for .*eql_v3\.text_eq/i)
233+
})
234+
235+
it('wrong-domain: an eq-shaped term used as text_ord is rejected, not silently coerced', async () => {
236+
// An eq value has {c,i,v,hm} but no ob. The text_ord `=` operator extracts the
237+
// ord term via eql_v3.ord_term → eql_v3.ore_block_u64_8_256(jsonb), which RAISEs
238+
// 'Expected an ore index (ob) value in json' for the missing ob. (text_ord_check
239+
// would also reject it; either is an intentional v3 guard, never silent coercion.)
240+
const eqEnc = unwrap(
241+
await pc.bulkEncryptModels([{ t_eq: 'banana' }], schema),
242+
)
243+
const eqTerm = JSON.stringify((eqEnc[0] as Record<string, unknown>).t_eq)
244+
await expect(
245+
sql.unsafe(
246+
`SELECT * FROM "${TABLE}" WHERE t_ord = '${eqTerm}'::jsonb::eql_v3.text_ord`,
247+
),
248+
).rejects.toThrow(/ore index \(ob\)|text_ord_check|23514/i)
249+
})
250+
251+
it('mapping gaps are loud (pure function rejects out-of-scope tuples)', async () => {
252+
const { eqlV3Domain } = await import('../src/pg/v3/domain-map')
253+
// @ts-expect-error number has no v3 domain in this milestone
254+
expect(() => eqlV3Domain('number', 'equality')).toThrow(/unsupported/i)
255+
})
256+
})
257+
258+
describe.skipIf(!HAS_DB)('v3 functional index definitions', () => {
259+
it('eq_term / match_term / ord_term functional indexes exist on the table', async () => {
260+
const rows = await sql`
261+
SELECT indexname, indexdef FROM pg_indexes
262+
WHERE tablename = ${TABLE}
263+
`
264+
const defs = rows.map((r) => r.indexdef as string).join('\n')
265+
expect(defs).toContain('eq_term')
266+
expect(defs).toContain('match_term')
267+
expect(defs).toContain('ord_term')
268+
})
269+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export type V3SeedRow = { label: string }
2+
3+
/** Lexicographic spread for ord; shared-trigram pair (aardvark/aard) for match. */
4+
export const v3SeedData: V3SeedRow[] = [
5+
{ label: 'aardvark' },
6+
{ label: 'aard' },
7+
{ label: 'banana' },
8+
{ label: 'cherry' },
9+
{ label: 'date' },
10+
]
11+
12+
export function oracleEq(target: string): V3SeedRow[] {
13+
return v3SeedData.filter((r) => r.label === target)
14+
}
15+
export function oracleNe(target: string): V3SeedRow[] {
16+
return v3SeedData.filter((r) => r.label !== target)
17+
}
18+
export function oracleContains(sub: string): V3SeedRow[] {
19+
return v3SeedData.filter((r) => r.label.includes(sub))
20+
}
21+
export function oracleLt(target: string): V3SeedRow[] {
22+
return v3SeedData.filter((r) => r.label < target)
23+
}
24+
export function oracleAscLabels(): string[] {
25+
return [...v3SeedData]
26+
.sort((a, b) => a.label.localeCompare(b.label))
27+
.map((r) => r.label)
28+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { readFileSync } from 'node:fs'
2+
import { fileURLToPath } from 'node:url'
3+
import type postgres from 'postgres'
4+
5+
const SQL_PATH = fileURLToPath(
6+
new URL('../../fixtures/cipherstash-encrypt-v3.sql', import.meta.url),
7+
)
8+
9+
/**
10+
* Installs the v3 SQL. Multi-statement DDL requires sql.unsafe (tagged-template
11+
* sql`` runs a single statement).
12+
*
13+
* Idempotency is GUARDED, not via CREATE OR REPLACE: the EQL v3 SQL hardcodes the
14+
* `eql_v3` schema and its `CREATE DOMAIN`/`CREATE OPERATOR` have no OR REPLACE, so a
15+
* blind re-install errors on the second run. We therefore probe for `eql_v3.text_eq`
16+
* and skip the install if it already exists.
17+
*
18+
* The probe-then-install runs inside ONE transaction holding a transaction-scoped
19+
* advisory lock, so it is also concurrency-safe: the `eql_v3` schema is global and
20+
* shared across the protect/stack/drizzle suites Turbo runs, and without the lock
21+
* two suites could both pass the probe before either installs, then race on
22+
* `CREATE DOMAIN` (which has no OR REPLACE). A `pg_advisory_xact_lock` serialises
23+
* that window — the first holder installs, the rest see `text_eq` present and
24+
* no-op (§9b). It MUST be a transaction (not a bare session lock): `sql` is a
25+
* connection pool, so a session lock and its unlock could land on different pooled
26+
* connections (and would not survive PgBouncer transaction mode). A single
27+
* `sql.begin` keeps the lock, probe, and install on one connection and auto-releases
28+
* the lock at commit.
29+
*/
30+
const INSTALL_LOCK_KEY = 'eql_v3_install'
31+
32+
export async function installEqlV3(sql: postgres.Sql): Promise<void> {
33+
const ddl = readFileSync(SQL_PATH, 'utf-8')
34+
await sql.begin(async (tx) => {
35+
await tx`SELECT pg_advisory_xact_lock(hashtext(${INSTALL_LOCK_KEY}))`
36+
const [present] = await tx`
37+
SELECT 1 FROM pg_type t
38+
JOIN pg_namespace n ON n.oid = t.typnamespace
39+
WHERE n.nspname = 'eql_v3' AND t.typname = 'text_eq'
40+
`
41+
if (present) return // already installed by an earlier run / sibling suite
42+
await tx.unsafe(ddl)
43+
})
44+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import 'dotenv/config'
2+
import postgres from 'postgres'
3+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
4+
import { installEqlV3 } from './helpers/install-v3'
5+
6+
// DB-gated suite: without DATABASE_URL the whole describe is skipped (not failed),
7+
// so a bare `pnpm test` reports skips rather than a hard import-time throw.
8+
const HAS_DB = !!process.env.DATABASE_URL
9+
10+
let sql: ReturnType<typeof postgres>
11+
12+
beforeAll(async () => {
13+
if (!HAS_DB) return
14+
// { prepare: false } is required for the pooled CI DB (PgBouncer transaction
15+
// mode) — mirrors packages/protect/__tests__/searchable-json-pg.test.ts:12.
16+
sql = postgres(process.env.DATABASE_URL as string, { prepare: false })
17+
await installEqlV3(sql)
18+
}, 120000)
19+
20+
afterAll(async () => {
21+
await sql?.end()
22+
})
23+
24+
describe.skipIf(!HAS_DB)('v3 DB provisioning', () => {
25+
it('installs the eql_v3 schema and its text_eq domain', async () => {
26+
const rows = await sql`
27+
SELECT 1 FROM pg_type t
28+
JOIN pg_namespace n ON n.oid = t.typnamespace
29+
WHERE n.nspname = 'eql_v3' AND t.typname = 'text_eq'
30+
`
31+
expect(rows.length).toBe(1)
32+
})
33+
34+
it('the v3 extractor functions were installed (eq_term, ord_term, match_term)', async () => {
35+
const fns = await sql`
36+
SELECT proname FROM pg_proc p
37+
JOIN pg_namespace n ON n.oid = p.pronamespace
38+
WHERE n.nspname = 'eql_v3' AND p.proname IN ('eq_term','ord_term','match_term')
39+
`
40+
// >= 3, not == 3: each extractor is overloaded per scalar (text/int2/int4/int8/
41+
// date/timestamptz/…), so there are many rows. At least one per name proves the
42+
// DDL executed rather than silently no-op'ing.
43+
expect(fns.length).toBeGreaterThanOrEqual(3)
44+
})
45+
})

0 commit comments

Comments
 (0)