|
| 1 | +/** |
| 2 | + * Query-type matrix for `@cipherstash/stack/wasm-inline`'s `encryptQuery` / |
| 3 | + * `encryptQueryBulk` (#662) — the WASM twin of the per-query-type coverage |
| 4 | + * the Drizzle/Supabase adapters have. |
| 5 | + * |
| 6 | + * Runs under Deno against real CipherStash credentials, one live term per |
| 7 | + * query type the surface supports: |
| 8 | + * |
| 9 | + * | queryType | column domain | FFI index | |
| 10 | + * |------------------|----------------------|------------| |
| 11 | + * | equality | eql_v3_text_eq | unique | |
| 12 | + * | freeTextSearch | eql_v3_text_search | match | |
| 13 | + * | orderAndRange | eql_v3_integer_ord | ore | |
| 14 | + * | searchableJson | eql_v3_json | ste_vec | |
| 15 | + * | (string value) | → ste_vec_selector | | |
| 16 | + * | (object value) | → ste_vec_term | | |
| 17 | + * |
| 18 | + * Wire shapes differ by kind — which is exactly what this suite pins: |
| 19 | + * SCALAR terms are v3 envelopes (`{v: 3, i, …}`); a JSON CONTAINMENT |
| 20 | + * needle is a strict `{sv: [query-entry]}` with no version field; a |
| 21 | + * SELECTOR query returns the BARE selector-hash string (v3 has no |
| 22 | + * encrypted-selector envelope — bind as the text argument of `->`/`->>`). |
| 23 | + * All are CIPHERTEXT-FREE. The boundary bugs this suite's first runs |
| 24 | + * caught (undefined fields rejected by serde; the bulk field is `queries`; |
| 25 | + * the ore→ope swap; both JSON shapes) are exactly why each type needs a |
| 26 | + * live crossing, not a mock. |
| 27 | + * |
| 28 | + * FAILS LOUDLY when any CS_* env var is missing, matching `roundtrip.test.ts`. |
| 29 | + */ |
| 30 | + |
| 31 | +import { assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0' |
| 32 | +import { |
| 33 | + Encryption, |
| 34 | + encryptedTable, |
| 35 | + types, |
| 36 | +} from '@cipherstash/stack/wasm-inline' |
| 37 | + |
| 38 | +const REQUIRED_ENV = [ |
| 39 | + 'CS_WORKSPACE_CRN', |
| 40 | + 'CS_CLIENT_ACCESS_KEY', |
| 41 | + 'CS_CLIENT_ID', |
| 42 | + 'CS_CLIENT_KEY', |
| 43 | +] as const |
| 44 | + |
| 45 | +function requireEnv(): Record<(typeof REQUIRED_ENV)[number], string> { |
| 46 | + const values = {} as Record<(typeof REQUIRED_ENV)[number], string> |
| 47 | + const missing: string[] = [] |
| 48 | + for (const key of REQUIRED_ENV) { |
| 49 | + const value = Deno.env.get(key) |
| 50 | + if (value) values[key] = value |
| 51 | + else missing.push(key) |
| 52 | + } |
| 53 | + if (missing.length > 0) { |
| 54 | + // FAIL, don't skip: a silently-skipped credential suite reads as green |
| 55 | + // coverage that never ran (same doctrine as the repo's integration |
| 56 | + // harness — no skipIf credential gates). |
| 57 | + throw new Error( |
| 58 | + `Missing required env: ${missing.join(', ')}. This suite needs real ` + |
| 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.', |
| 62 | + ) |
| 63 | + } |
| 64 | + return values |
| 65 | +} |
| 66 | + |
| 67 | +const catalog = encryptedTable('wasm_query_matrix', { |
| 68 | + email: types.TextEq('email'), // equality only |
| 69 | + bio: types.TextSearch('bio'), // free-text (also carries eq + ore) |
| 70 | + age: types.IntegerOrd('age'), // eq + order/range |
| 71 | + prefs: types.Json('prefs'), // searchable JSON (ste_vec) |
| 72 | +}) |
| 73 | + |
| 74 | +/** A v3 SCALAR query term: versioned envelope (`v: 3`), NO ciphertext. */ |
| 75 | +function assertV3Term(term: unknown, label: string) { |
| 76 | + assertExists(term, `${label}: encryptQuery returned null`) |
| 77 | + const obj = term as Record<string, unknown> |
| 78 | + assertEquals(obj.v, 3, `${label}: term is not EQL v3`) |
| 79 | + assertEquals('c' in obj, false, `${label}: term carries ciphertext`) |
| 80 | +} |
| 81 | + |
| 82 | +/** A v3 JSON CONTAINMENT needle: strict `{sv: [query-entry]}` — no `v` |
| 83 | + * envelope, no ciphertext (per `is_valid_ste_vec_query_payload`). */ |
| 84 | +function assertContainmentNeedle(term: unknown, label: string) { |
| 85 | + assertExists(term, `${label}: encryptQuery returned null`) |
| 86 | + const obj = term as Record<string, unknown> |
| 87 | + assertEquals( |
| 88 | + Array.isArray(obj.sv), |
| 89 | + true, |
| 90 | + `${label}: expected an {sv: [...]} containment needle`, |
| 91 | + ) |
| 92 | + assertEquals('c' in obj, false, `${label}: needle carries ciphertext`) |
| 93 | + assertEquals('v' in obj, false, `${label}: needle carries a version envelope`) |
| 94 | +} |
| 95 | + |
| 96 | +Deno.test({ |
| 97 | + name: 'stack/wasm-inline: encryptQuery covers every v3 query type', |
| 98 | + permissions: { |
| 99 | + env: true, |
| 100 | + net: true, |
| 101 | + read: true, |
| 102 | + sys: true, |
| 103 | + // No FFI permission, same pin as roundtrip.test.ts: if protect-ffi ever |
| 104 | + // silently fell back to a native binding under Deno, the call would |
| 105 | + // reject — so a green run proves the WASM path minted every term. |
| 106 | + ffi: false, |
| 107 | + }, |
| 108 | + async fn() { |
| 109 | + const env = requireEnv() |
| 110 | + const client = await Encryption({ |
| 111 | + schemas: [catalog], |
| 112 | + config: { |
| 113 | + workspaceCrn: env.CS_WORKSPACE_CRN, |
| 114 | + accessKey: env.CS_CLIENT_ACCESS_KEY, |
| 115 | + clientId: env.CS_CLIENT_ID, |
| 116 | + clientKey: env.CS_CLIENT_KEY, |
| 117 | + }, |
| 118 | + }) |
| 119 | + |
| 120 | + // equality → unique index |
| 121 | + assertV3Term( |
| 122 | + await client.encryptQuery('alice@example.com', { |
| 123 | + table: catalog, |
| 124 | + column: catalog.email, |
| 125 | + queryType: 'equality', |
| 126 | + }), |
| 127 | + 'equality', |
| 128 | + ) |
| 129 | + |
| 130 | + // freeTextSearch → match index |
| 131 | + assertV3Term( |
| 132 | + await client.encryptQuery('needle phrase', { |
| 133 | + table: catalog, |
| 134 | + column: catalog.bio, |
| 135 | + queryType: 'freeTextSearch', |
| 136 | + }), |
| 137 | + 'freeTextSearch', |
| 138 | + ) |
| 139 | + |
| 140 | + // orderAndRange → ore index (numeric) |
| 141 | + assertV3Term( |
| 142 | + await client.encryptQuery(42, { |
| 143 | + table: catalog, |
| 144 | + column: catalog.age, |
| 145 | + queryType: 'orderAndRange', |
| 146 | + }), |
| 147 | + 'orderAndRange', |
| 148 | + ) |
| 149 | + |
| 150 | + // searchableJson, string value → ste_vec_selector (JSONPath). By |
| 151 | + // contract the selector "term" is a BARE selector-hash string — no |
| 152 | + // envelope — bound as the text argument of `->` / `->>`. |
| 153 | + const selector = await client.encryptQuery('$.theme', { |
| 154 | + table: catalog, |
| 155 | + column: catalog.prefs, |
| 156 | + queryType: 'searchableJson', |
| 157 | + }) |
| 158 | + assertExists(selector, 'selector: encryptQuery returned null') |
| 159 | + assertEquals( |
| 160 | + typeof selector, |
| 161 | + 'string', |
| 162 | + 'selector: expected the bare selector-hash string', |
| 163 | + ) |
| 164 | + assertEquals( |
| 165 | + (selector as unknown as string).length > 0, |
| 166 | + true, |
| 167 | + 'selector: empty selector hash', |
| 168 | + ) |
| 169 | + |
| 170 | + // searchableJson, object value → ste_vec_term (containment needle: |
| 171 | + // strict {sv: [...]}, no version envelope — per the eql_v3.query_jsonb |
| 172 | + // wire contract) |
| 173 | + assertContainmentNeedle( |
| 174 | + await client.encryptQuery( |
| 175 | + { theme: 'dark' }, |
| 176 | + { |
| 177 | + table: catalog, |
| 178 | + column: catalog.prefs, |
| 179 | + queryType: 'searchableJson', |
| 180 | + }, |
| 181 | + ), |
| 182 | + 'searchableJson/containment', |
| 183 | + ) |
| 184 | + |
| 185 | + // Omitted queryType → inference from the column's indexes (TextEq has |
| 186 | + // exactly one: unique), mirroring the native client. |
| 187 | + assertV3Term( |
| 188 | + await client.encryptQuery('bob@example.com', { |
| 189 | + table: catalog, |
| 190 | + column: catalog.email, |
| 191 | + }), |
| 192 | + 'inference', |
| 193 | + ) |
| 194 | + |
| 195 | + // Bulk: one round trip across mixed query types, position-stable with |
| 196 | + // nulls passing through. |
| 197 | + const bulk = await client.encryptQueryBulk([ |
| 198 | + { |
| 199 | + value: 'alice@example.com', |
| 200 | + table: catalog, |
| 201 | + column: catalog.email, |
| 202 | + queryType: 'equality', |
| 203 | + }, |
| 204 | + { |
| 205 | + value: null, |
| 206 | + table: catalog, |
| 207 | + column: catalog.email, |
| 208 | + }, |
| 209 | + { |
| 210 | + value: 'needle', |
| 211 | + table: catalog, |
| 212 | + column: catalog.bio, |
| 213 | + queryType: 'freeTextSearch', |
| 214 | + }, |
| 215 | + { |
| 216 | + value: 7, |
| 217 | + table: catalog, |
| 218 | + column: catalog.age, |
| 219 | + queryType: 'orderAndRange', |
| 220 | + }, |
| 221 | + { |
| 222 | + value: { theme: 'dark' }, |
| 223 | + table: catalog, |
| 224 | + column: catalog.prefs, |
| 225 | + queryType: 'searchableJson', |
| 226 | + }, |
| 227 | + ]) |
| 228 | + assertEquals(bulk.length, 5) |
| 229 | + assertV3Term(bulk[0], 'bulk/equality') |
| 230 | + assertEquals(bulk[1], null, 'bulk: null value must yield null') |
| 231 | + assertV3Term(bulk[2], 'bulk/freeTextSearch') |
| 232 | + assertV3Term(bulk[3], 'bulk/orderAndRange') |
| 233 | + assertContainmentNeedle(bulk[4], 'bulk/searchableJson') |
| 234 | + }, |
| 235 | +}) |
0 commit comments