Skip to content

Commit a2cb138

Browse files
committed
fix(stack): code-review round on #676 — validation parity and test hardening
Product fix: the WASM encryptQuery/encryptQueryBulk now run the same pre-FFI guards as every other query path (assertValidNumericValue, assertValueIndexCompatibility) — NaN/Infinity/out-of-int64 bigint and numeric-on-match-index fail with the named errors the Node entry raises instead of an opaque serde failure or a silently no-match term. Both paths now share one toFfiQueryTerm() so the serde-omission and ope-swap subtleties can't drift between single and bulk; the deliberate as-never casts at the FFI seam carry biome-ignore reasons per AGENTS.md. Test hardening from the same review: - FFI stub gains encryptQuery/encryptQueryBulk so unmocked unit tests fail with the stub's named error, not 'is not a function' - query-types.test.ts gains roundtrip's ffi:false permissions pin - wasm adapter expectRejected discriminates capability rejections from infrastructure failures (mirrors the Supabase adapter's doctrine) - empty in/notIn lists throw (parity with drizzle inArrayOp) instead of rendering 'WHERE ()' - independent ZeroKMS round-trips run concurrently (per-field encrypts, bulk-insert rows, between lo/hi terms) - dropped the redundant '| null' from encryptQuery's signature and the gratuitous 'null as unknown as string' casts it appeared to justify - fixed the nonexistent .env.example pointer in both Deno suites and the stale skip-behavior comments in tests.yml/integration-drizzle.yml - three new unit pins for the validation behavior (828 total green) Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent 1a26d8c commit a2cb138

8 files changed

Lines changed: 188 additions & 74 deletions

File tree

.github/workflows/integration-drizzle.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,9 @@ jobs:
129129
# The Drizzle adapter suites (incl. the Clerk-federated lock-context one)
130130
# now live in @cipherstash/stack-drizzle (run below via its own
131131
# test:integration). This glob scopes what still lives in @cipherstash/stack:
132-
# the adapter-agnostic `shared/` core suites and the model-path
133-
# `identity/matrix-identity` suite.
132+
# the adapter-agnostic `shared/` core suites, the model-path
133+
# `identity/matrix-identity` suite, and the `wasm/` family suite (the
134+
# `@cipherstash/stack/wasm-inline` adapter over the shared v3 matrix).
134135
CS_IT_SUITE: >-
135136
integration/shared/**/*.integration.test.ts,
136137
integration/identity/**/*.integration.test.ts,
@@ -172,7 +173,7 @@ jobs:
172173
# different package now). Its globalSetup calls the same EQL v3 install, but
173174
# `isInstalled` short-circuits against the DB the first invocation already
174175
# provisioned — a fast no-op check, not a second schema apply.
175-
- name: Shared core + identity integration suites
176+
- name: Shared core + identity + wasm integration suites
176177
run: pnpm --filter @cipherstash/stack run test:integration
177178

178179
- name: Stop ${{ matrix.db }}

.github/workflows/tests.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,10 @@ jobs:
294294
- name: Build stack
295295
run: pnpm exec turbo run build --filter @cipherstash/stack
296296

297-
# roundtrip.test.ts skips itself (Deno.test.ignore) when any of the four
298-
# CS_* env vars is missing — fine locally, but in CI a silent skip would
299-
# let a rotated / cleared secret hide a real WASM regression behind a
300-
# green job. Fail loudly instead.
297+
# The e2e/wasm suites FAIL when any of the four CS_* env vars is
298+
# missing (requireEnv throws — no skip gating), so a rotated / cleared
299+
# secret can't hide a real WASM regression behind a green job. This
300+
# preflight just fails faster, before the Deno module downloads.
301301
- name: Require CipherStash secrets
302302
uses: ./.github/actions/require-cs-secrets
303303
with:

e2e/wasm/query-types.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ function requireEnv(): Record<(typeof REQUIRED_ENV)[number], string> {
5656
// harness — no skipIf credential gates).
5757
throw new Error(
5858
`Missing required env: ${missing.join(', ')}. This suite needs real ` +
59-
'CipherStash credentials — set the CS_* variables (see .env.example) ' +
60-
'or run via the CI job, which injects them.',
59+
'CipherStash credentials — export the four CS_* variables (or put them ' +
60+
'in a repo-root .env; see AGENTS.md "Environment variables") or run ' +
61+
'via the CI job, which injects them.',
6162
)
6263
}
6364
return values
@@ -93,6 +94,16 @@ function assertContainmentNeedle(term: unknown, label: string) {
9394

9495
Deno.test({
9596
name: 'stack/wasm-inline: encryptQuery covers every v3 query type',
97+
permissions: {
98+
env: true,
99+
net: true,
100+
read: true,
101+
sys: true,
102+
// No FFI permission, same pin as roundtrip.test.ts: if protect-ffi ever
103+
// silently fell back to a native binding under Deno, the call would
104+
// reject — so a green run proves the WASM path minted every term.
105+
ffi: false,
106+
},
96107
async fn() {
97108
const env = requireEnv()
98109
const client = await Encryption({
@@ -190,7 +201,7 @@ Deno.test({
190201
queryType: 'equality',
191202
},
192203
{
193-
value: null as unknown as string,
204+
value: null,
194205
table: catalog,
195206
column: catalog.email,
196207
},

e2e/wasm/roundtrip.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ function requireEnv(): Record<(typeof REQUIRED_ENV)[number], string> {
5151
// harness — no skipIf credential gates).
5252
throw new Error(
5353
`Missing required env: ${missing.join(', ')}. This suite needs real ` +
54-
'CipherStash credentials — set the CS_* variables (see .env.example) ' +
55-
'or run via the CI job, which injects them.',
54+
'CipherStash credentials — export the four CS_* variables (or put them ' +
55+
'in a repo-root .env; see AGENTS.md "Environment variables") or run ' +
56+
'via the CI job, which injects them.',
5657
)
5758
}
5859
return values
@@ -159,7 +160,7 @@ Deno.test({
159160
// Bulk form is position-stable, nulls pass through.
160161
const bulk = await client.encryptQueryBulk([
161162
{ value: plaintext, column: users.email, table: users },
162-
{ value: null as unknown as string, column: users.email, table: users },
163+
{ value: null, column: users.email, table: users },
163164
])
164165
assertEquals(bulk.length, 2)
165166
assertExists(bulk[0])

packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ export const encrypt = (): never => {
2020
)
2121
}
2222

23+
export const encryptQuery = (): never => {
24+
throw new Error(
25+
'[test stub]: protect-ffi/wasm-inline encryptQuery not implemented',
26+
)
27+
}
28+
29+
export const encryptQueryBulk = (): never => {
30+
throw new Error(
31+
'[test stub]: protect-ffi/wasm-inline encryptQueryBulk not implemented',
32+
)
33+
}
34+
2335
export const isEncrypted = (): boolean => false
2436

2537
export const newClient = (): never => {

packages/stack/__tests__/wasm-inline-query.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,48 @@ describe('WasmEncryptionClient.encryptQuery', () => {
170170
).toBeNull()
171171
expect(ffi.encryptQuery).not.toHaveBeenCalled()
172172
})
173+
174+
// The same pre-FFI guards the native client runs — an invalid value must
175+
// fail with the NAMED error before any FFI/network crossing, not with an
176+
// opaque serde failure (or a silently no-match term) from inside WASM.
177+
it('rejects NaN with the named validation error before the FFI', async () => {
178+
const c = await client()
179+
await expect(
180+
c.encryptQuery(Number.NaN, {
181+
table: users,
182+
column: users.age,
183+
queryType: 'orderAndRange',
184+
}),
185+
).rejects.toThrow('[encryption]: Cannot encrypt NaN value')
186+
expect(ffi.encryptQuery).not.toHaveBeenCalled()
187+
})
188+
189+
it('rejects a numeric value against a match index before the FFI', async () => {
190+
const c = await client()
191+
await expect(
192+
c.encryptQuery(42, {
193+
table: users,
194+
column: users.bio,
195+
queryType: 'freeTextSearch',
196+
}),
197+
).rejects.toThrow(/Cannot use 'match' index with numeric value/)
198+
expect(ffi.encryptQuery).not.toHaveBeenCalled()
199+
})
200+
201+
it('applies the same validation on the bulk path', async () => {
202+
const c = await client()
203+
await expect(
204+
c.encryptQueryBulk([
205+
{
206+
value: Number.POSITIVE_INFINITY,
207+
table: users,
208+
column: users.age,
209+
queryType: 'orderAndRange',
210+
},
211+
]),
212+
).rejects.toThrow('[encryption]: Cannot encrypt Infinity value')
213+
expect(ffi.encryptQueryBulk).not.toHaveBeenCalled()
214+
})
173215
})
174216

175217
describe('WasmEncryptionClient.encryptQueryBulk', () => {
@@ -181,7 +223,7 @@ describe('WasmEncryptionClient.encryptQueryBulk', () => {
181223
const c = await client()
182224
const out = await c.encryptQueryBulk([
183225
{ value: 'a@b.com', table: users, column: users.email },
184-
{ value: null as unknown as string, table: users, column: users.email },
226+
{ value: null, table: users, column: users.email },
185227
{
186228
value: 'needle',
187229
table: users,
@@ -207,7 +249,7 @@ describe('WasmEncryptionClient.encryptQueryBulk', () => {
207249
it('short-circuits an all-null batch', async () => {
208250
const c = await client()
209251
const out = await c.encryptQueryBulk([
210-
{ value: null as unknown as string, table: users, column: users.email },
252+
{ value: null, table: users, column: users.email },
211253
])
212254
expect(out).toEqual([null])
213255
expect(ffi.encryptQueryBulk).not.toHaveBeenCalled()

packages/stack/integration/wasm/adapter.ts

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -225,14 +225,18 @@ export function makeWasmAdapter(): IntegrationAdapter {
225225

226226
async function encryptRow(row: PlainRow): Promise<Record<string, unknown>> {
227227
const assignments: Record<string, unknown> = { row_key: row.rowKey }
228-
for (const [slug, value] of Object.entries(row.values)) {
229-
const encrypted = await client.encrypt(toWasmPlaintext(value), {
230-
table: tableSchema,
231-
column: col(slug).column,
232-
})
233-
assertWireEnvelope(slug, encrypted)
234-
assignments[slug] = encrypted
235-
}
228+
// Field encrypts are independent ZeroKMS round-trips — run them
229+
// concurrently rather than paying fields × RTT per row.
230+
await Promise.all(
231+
Object.entries(row.values).map(async ([slug, value]) => {
232+
const encrypted = await client.encrypt(toWasmPlaintext(value), {
233+
table: tableSchema,
234+
column: col(slug).column,
235+
})
236+
assertWireEnvelope(slug, encrypted)
237+
assignments[slug] = encrypted
238+
}),
239+
)
236240
return assignments
237241
}
238242

@@ -253,6 +257,12 @@ export function makeWasmAdapter(): IntegrationAdapter {
253257
}
254258
case 'in':
255259
case 'notIn': {
260+
// Parity with the production operators (drizzle inArrayOp): an
261+
// empty list would otherwise render `WHERE ()` — a syntax error,
262+
// not empty-IN semantics.
263+
if (op.values.length === 0) {
264+
throw new Error(`${op.kind} requires a non-empty list of values`)
265+
}
256266
// One encryptQueryBulk crossing for the whole list — the bulk path
257267
// gets exercised on every family this way.
258268
const { column, eqlType } = col(op.column)
@@ -274,8 +284,10 @@ export function makeWasmAdapter(): IntegrationAdapter {
274284
}
275285
case 'between':
276286
case 'notBetween': {
277-
const lo = await term(op.column, op.lo, op.kind)
278-
const hi = await term(op.column, op.hi, op.kind)
287+
const [lo, hi] = await Promise.all([
288+
term(op.column, op.lo, op.kind),
289+
term(op.column, op.hi, op.kind),
290+
])
279291
// Parenthesised conjunction, mirroring the Drizzle dialect's
280292
// load-bearing parentheses (NOT binds tighter than AND).
281293
const range = `(eql_v3.gte("${op.column}", $1::jsonb::${lo.cast}) AND eql_v3.lte("${op.column}", $2::jsonb::${hi.cast}))`
@@ -372,20 +384,40 @@ export function makeWasmAdapter(): IntegrationAdapter {
372384
},
373385

374386
async insertBulk(_spec: TableSpec, rows: readonly PlainRow[]) {
375-
for (const row of rows) {
376-
await insertRow(await encryptRow(row))
377-
}
387+
// Rows are independent (distinct row_key PKs, no ordering
388+
// dependency) — encrypt and insert them concurrently.
389+
await Promise.all(
390+
rows.map(async (row) => insertRow(await encryptRow(row))),
391+
)
378392
},
379393

380394
async run(_spec: TableSpec, op: QueryOp) {
381395
return run(op)
382396
},
383397

398+
// Discriminate capability rejections from infrastructure failures —
399+
// this adapter's run() path is fully live (ZeroKMS + real SQL), so a
400+
// bare catch would record a connection reset or a dropped table as a
401+
// passing negative test. Same doctrine as the Supabase adapter's
402+
// expectRejected. Two shapes are legitimate rejections here:
403+
// - client-side: resolveIndexType / the pre-FFI validators throw when
404+
// the queryType isn't configured on the column;
405+
// - server-side (order ops only): domains without an ordering index
406+
// have no eql_v3.ord_term overload, so Postgres rejects the call.
384407
async expectRejected(_spec: TableSpec, op: QueryOp) {
385408
try {
386409
await run(op)
387-
} catch {
388-
return
410+
} catch (error) {
411+
const message = error instanceof Error ? error.message : String(error)
412+
const isCapabilityRejection =
413+
/is not configured on column|has no indexes configured|\[encryption\]:/.test(
414+
message,
415+
) || /ord_term/.test(message)
416+
if (isCapabilityRejection) return
417+
throw new Error(
418+
`Expected ${op.kind} on "${op.column}" to be rejected by a capability error, but got an unrelated failure: ${message}`,
419+
{ cause: error },
420+
)
389421
}
390422
throw new Error(
391423
`Expected ${op.kind} on "${op.column}" to be rejected, but it ran.`,

0 commit comments

Comments
 (0)