Skip to content

Commit c43777e

Browse files
committed
test(stack): strengthen v3 live coverage (range/boundary/lock-context/null)
Close assertion-strength gaps in the EQL v3 live suites where a real regression could pass green: - between/notBetween: add narrow single-point range cases that prove EXCLUSION (the spanning cases only ever proved inclusion). - ordering oracle: compare text bytewise, not via localeCompare, to match ORE byte order. - inArray/notInArray: exercise the >4-value concurrency pool. - matrix-boundary-live-pg: drive catalog boundary samples (INT4/smallint bounds, 1e15) through real eql_v3.<type> columns, not just the FFI. - operators-lock-context-live-pg: query lock-context-bound rows through the Drizzle operator path (ops.eq + lockContext/audit); soft-skips on USER_JWT. - operators-null-live-pg: NULL persistence across storage/eq/ord/match tiers. - live-gate-required guard + CI: fail loudly (REQUIRE_LIVE/REQUIRE_LIVE_PG) instead of silently skipping the live matrices when creds are absent.
1 parent b06cad8 commit c43777e

6 files changed

Lines changed: 624 additions & 1 deletion

File tree

.github/workflows/tests.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ jobs:
8585
echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/stack/.env
8686
echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env
8787
echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env
88+
# Fail loudly if the live suites would silently skip (missing creds):
89+
# the skip-guard in __tests__/live-gate-required.test.ts asserts the
90+
# live gates are actually enabled when these are set.
91+
echo "REQUIRE_LIVE=1" >> ./packages/stack/.env
92+
echo "REQUIRE_LIVE_PG=1" >> ./packages/stack/.env
8893
8994
- name: Create .env file in ./packages/protect-dynamodb/
9095
run: |
@@ -297,6 +302,11 @@ jobs:
297302
echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/stack/.env
298303
echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env
299304
echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env
305+
# Fail loudly if the live suites would silently skip (missing creds):
306+
# the skip-guard in __tests__/live-gate-required.test.ts asserts the
307+
# live gates are actually enabled when these are set.
308+
echo "REQUIRE_LIVE=1" >> ./packages/stack/.env
309+
echo "REQUIRE_LIVE_PG=1" >> ./packages/stack/.env
300310
301311
- name: Create .env file in ./packages/protect-dynamodb/
302312
run: |

packages/stack/__tests__/drizzle-v3/operators-live-pg.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,12 @@ function comparePlain(left: PlainValue, right: PlainValue): number {
120120
return left - right
121121
}
122122
if (typeof left === 'string' && typeof right === 'string') {
123-
return left.localeCompare(right)
123+
// eql_v3 text ordering (ORE) is BYTEWISE, not locale-collated: the oracle
124+
// must model codepoint order, not `localeCompare` (which folds case,
125+
// reorders punctuation vs letters, and is locale-dependent). Text samples
126+
// must stay ASCII/unambiguous so UTF-16 code-unit order == the byte order
127+
// the DB actually sorts by.
128+
return left < right ? -1 : left > right ? 1 : 0
124129
}
125130
throw new Error(
126131
`Unsupported ordered values: ${String(left)}, ${String(right)}`,
@@ -318,6 +323,39 @@ describeLivePg('v3 drizzle operators (live pg matrix)', () => {
318323
30000,
319324
)
320325

326+
// The spanning cases above only ever prove INCLUSION (both rows are inside
327+
// the range, so `between` -> [A,B] and `notBetween` -> []). These narrow
328+
// cases use a single-point range at ROW_A's value to prove the operators
329+
// also EXCLUDE: `between` must drop ROW_B, `notBetween` must keep it. Without
330+
// these, a `between` that matched everything (or a `notBetween` no-op) passes.
331+
it.each(orderDomains)(
332+
'%s between at a single point excludes the out-of-range row',
333+
async (eqlType, spec) => {
334+
const bound = plainValue(spec, ROW_A)
335+
const rows = await selectRowKeys(
336+
await ops.between(matrixColumn(eqlType), bound, bound),
337+
)
338+
expect(rows).toEqual(
339+
expectedKeysFor(spec, (value) => comparePlain(value, bound) === 0),
340+
)
341+
},
342+
30000,
343+
)
344+
345+
it.each(orderDomains)(
346+
'%s notBetween at a single point keeps the out-of-range row',
347+
async (eqlType, spec) => {
348+
const bound = plainValue(spec, ROW_A)
349+
const rows = await selectRowKeys(
350+
await ops.notBetween(matrixColumn(eqlType), bound, bound),
351+
)
352+
expect(rows).toEqual(
353+
expectedKeysFor(spec, (value) => comparePlain(value, bound) !== 0),
354+
)
355+
},
356+
30000,
357+
)
358+
321359
it.each(orderDomains)(
322360
'%s asc orders by encrypted order term',
323361
async (eqlType, spec) => {
@@ -484,4 +522,38 @@ describeLivePg('v3 drizzle operators (live pg matrix)', () => {
484522
sortedKeysFor(spec, 'asc').slice(1, 2),
485523
)
486524
}, 30000)
525+
526+
// The matrix inArray/notInArray cases above use 2-element lists, so the
527+
// MAX_IN_ARRAY_CONCURRENCY=4 worker pool (operators.ts) never actually
528+
// concurrently encrypts more terms than the serial path would. These cross
529+
// the pool boundary: 5 values (> 4) forces the pool to reuse workers, and
530+
// must still produce the correct OR/AND of eq/ne terms.
531+
it('inArray encrypts a >4-value list through the concurrency pool', async () => {
532+
const rows = await selectRowKeys(
533+
await ops.inArray(matrixColumn('eql_v3.text_eq'), [
534+
'ada@example.com',
535+
'',
536+
'nobody-1@example.com',
537+
'nobody-2@example.com',
538+
'nobody-3@example.com',
539+
]),
540+
)
541+
// '' -> ROW_A, 'ada@example.com' -> ROW_B; the three "nobody" terms match
542+
// nothing, exercising the pool without changing the expected set.
543+
expect(rows).toEqual([ROW_A, ROW_B])
544+
}, 30000)
545+
546+
it('notInArray encrypts a >4-value list through the concurrency pool', async () => {
547+
const rows = await selectRowKeys(
548+
await ops.notInArray(matrixColumn('eql_v3.text_eq'), [
549+
'',
550+
'nobody-1@example.com',
551+
'nobody-2@example.com',
552+
'nobody-3@example.com',
553+
'nobody-4@example.com',
554+
]),
555+
)
556+
// Only '' is excluded (ROW_A); ROW_B ('ada@example.com') survives.
557+
expect(rows).toEqual([ROW_B])
558+
}, 30000)
487559
})
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* Live, identity-bound querying through the v3 Drizzle operator path.
3+
*
4+
* `matrix-identity-live.test.ts` proves lock context round-trips through the
5+
* typed CLIENT (`encryptModel`/`decryptModel`), and `operators.test.ts` proves
6+
* the Drizzle operators FORWARD `lockContext`/`audit` — but only against a
7+
* MOCKED FFI. Nothing exercises the one end-to-end shape that matters most:
8+
* seed rows bound to an identity, then query them with `ops.eq(col, value,
9+
* { lockContext })` against a real database and assert the encrypted term
10+
* actually matches the stored row and decrypts.
11+
*
12+
* Wiring mirrors `lock-context.test.ts` (the current, non-deprecated
13+
* strategy-based flow): the client authenticates as the end user via
14+
* `OidcFederationStrategy` and binds the key to the `sub` claim with a plain
15+
* `.withLockContext({ identityClaim })`.
16+
*
17+
* Gated twice: `describeLivePg` (needs `DATABASE_URL` + CS creds) and an inner
18+
* `USER_JWT` guard (soft-skip, matching the existing identity/lock-context
19+
* suites). Whether the searchable index terms are themselves identity-bound is
20+
* decided inside `@cipherstash/protect-ffi`, not this repo — so we assert the
21+
* SYMMETRIC behaviour (same lock context on seed + query matches and decrypts),
22+
* not a cross-identity non-match.
23+
*/
24+
import 'dotenv/config'
25+
import { OidcFederationStrategy } from '@cipherstash/auth'
26+
import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm'
27+
import { integer, pgTable, text } from 'drizzle-orm/pg-core'
28+
import { drizzle } from 'drizzle-orm/postgres-js'
29+
import postgres from 'postgres'
30+
import { afterAll, beforeAll, expect, it } from 'vitest'
31+
import { EncryptionV3 } from '@/encryption/v3'
32+
import {
33+
createEncryptionOperatorsV3,
34+
extractEncryptionSchemaV3,
35+
} from '@/eql/v3/drizzle'
36+
import { makeEqlV3Column } from '@/eql/v3/drizzle/column'
37+
import { installEqlV3IfNeeded } from '../helpers/eql-v3'
38+
import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from '../helpers/live-gate'
39+
import { V3_MATRIX } from '../v3-matrix/catalog'
40+
41+
const url = process.env.DATABASE_URL
42+
const sqlClient = LIVE_EQL_V3_PG_ENABLED
43+
? postgres(url as string, { prepare: false })
44+
: (undefined as unknown as postgres.Sql)
45+
46+
const TABLE_NAME = 'protect_ci_v3_drizzle_lock_context'
47+
const RUN = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
48+
const ROW_A = 'row-a'
49+
const ROW_B = 'row-b'
50+
const SECRET_A = 'ada@example.com'
51+
const SECRET_B = 'grace@example.com'
52+
53+
// A fixed identity claim; the same value must be supplied on encrypt and query
54+
// for the terms/keys to reproduce.
55+
const IDENTITY_CLAIM = { identityClaim: ['sub'] }
56+
57+
const secretTable = pgTable(TABLE_NAME, {
58+
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
59+
rowKey: text('row_key').notNull(),
60+
testRunId: text('test_run_id').notNull(),
61+
secret: makeEqlV3Column(V3_MATRIX['eql_v3.text_eq'].builder('secret')),
62+
} as never)
63+
64+
const schema = extractEncryptionSchemaV3(secretTable)
65+
66+
type SelectRow = { rowKey: string }
67+
68+
let client: Awaited<ReturnType<typeof EncryptionV3>>
69+
let ops: ReturnType<typeof createEncryptionOperatorsV3>
70+
let db: ReturnType<typeof drizzle>
71+
let userJwt: string | undefined
72+
73+
function unwrap<T>(result: { data?: T; failure?: { message: string } }): T {
74+
if (result.failure) throw new Error(result.failure.message)
75+
return result.data as T
76+
}
77+
78+
/** Run-scoped SELECT of row keys under an already-encrypted SQL condition. */
79+
async function selectRowKeys(condition: SQL): Promise<string[]> {
80+
const rows = (await db
81+
.select({ rowKey: secretTable.rowKey })
82+
.from(secretTable)
83+
.where(and(drizzleEq(secretTable.testRunId, RUN), condition))
84+
.orderBy(drizzleAsc(secretTable.rowKey))) as SelectRow[]
85+
return rows.map((row) => row.rowKey)
86+
}
87+
88+
beforeAll(async () => {
89+
if (!LIVE_EQL_V3_PG_ENABLED) return
90+
userJwt = process.env.USER_JWT
91+
if (!userJwt) return
92+
93+
const crn = process.env.CS_WORKSPACE_CRN
94+
if (!crn)
95+
throw new Error('CS_WORKSPACE_CRN must be set for lock-context tests')
96+
97+
await installEqlV3IfNeeded(sqlClient)
98+
client = await EncryptionV3({
99+
schemas: [schema],
100+
config: {
101+
strategy: OidcFederationStrategy.create(crn, () =>
102+
Promise.resolve(userJwt as string),
103+
),
104+
},
105+
})
106+
ops = createEncryptionOperatorsV3(client)
107+
db = drizzle({ client: sqlClient })
108+
109+
await sqlClient.unsafe(`
110+
CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
111+
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
112+
row_key TEXT NOT NULL,
113+
test_run_id TEXT NOT NULL,
114+
secret eql_v3.text_eq NOT NULL
115+
)
116+
`)
117+
118+
// Seed BOTH rows bound to the same lock context.
119+
const encryptedRows = unwrap<Array<Record<string, unknown>>>(
120+
await client
121+
.bulkEncryptModels(
122+
[
123+
{ rowKey: ROW_A, testRunId: RUN, secret: SECRET_A },
124+
{ rowKey: ROW_B, testRunId: RUN, secret: SECRET_B },
125+
] as never,
126+
schema,
127+
)
128+
.withLockContext(IDENTITY_CLAIM),
129+
)
130+
await db.insert(secretTable).values(encryptedRows as never)
131+
}, 120000)
132+
133+
afterAll(async () => {
134+
if (!LIVE_EQL_V3_PG_ENABLED) return
135+
if (userJwt) {
136+
await sqlClient`DELETE FROM ${sqlClient(TABLE_NAME)} WHERE test_run_id = ${RUN}`
137+
}
138+
await sqlClient.end()
139+
}, 30000)
140+
141+
describeLivePg('v3 drizzle operators with lock context (live pg)', () => {
142+
const skipUnlessJwt = (): boolean => {
143+
if (!userJwt) {
144+
console.log('Skipping lock-context operator test - no USER_JWT provided')
145+
return true
146+
}
147+
return false
148+
}
149+
150+
it('eq with a matching lock context selects the exact row', async () => {
151+
if (skipUnlessJwt()) return
152+
const condition = await ops.eq(secretTable.secret, SECRET_A, {
153+
// Runtime accepts a plain { identityClaim } (forwarded to
154+
// withLockContext); the operator opts type is narrowed to LockContext.
155+
lockContext: IDENTITY_CLAIM as never,
156+
})
157+
expect(await selectRowKeys(condition)).toEqual([ROW_A])
158+
}, 30000)
159+
160+
it('a lock-context-bound row decrypts with the same lock context', async () => {
161+
if (skipUnlessJwt()) return
162+
const [row] = await sqlClient.unsafe<Array<{ value: unknown }>>(
163+
`SELECT secret::jsonb AS value FROM ${TABLE_NAME}
164+
WHERE test_run_id = $1 AND row_key = $2`,
165+
[RUN, ROW_A],
166+
)
167+
const decrypted = unwrap(
168+
await client.decrypt(row.value as never).withLockContext(IDENTITY_CLAIM),
169+
)
170+
expect(decrypted).toBe(SECRET_A)
171+
}, 30000)
172+
173+
it('eq threads an audit config alongside the lock context', async () => {
174+
if (skipUnlessJwt()) return
175+
const condition = await ops.eq(secretTable.secret, SECRET_B, {
176+
lockContext: IDENTITY_CLAIM as never,
177+
audit: { metadata: { sub: 'toby@cipherstash.com', type: 'query' } },
178+
})
179+
expect(await selectRowKeys(condition)).toEqual([ROW_B])
180+
}, 30000)
181+
})

0 commit comments

Comments
 (0)