|
| 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 | +}) |
0 commit comments