|
| 1 | +import type { EncryptionClient } from '@cipherstash/stack/encryption' |
| 2 | +import { encryptedTable, types } from '@cipherstash/stack/eql/v3' |
| 3 | +import { EncryptionErrorTypes } from '@cipherstash/stack/errors' |
| 4 | +import { |
| 5 | + encryptedColumn, |
| 6 | + encryptedTable as encryptedTableV2, |
| 7 | +} from '@cipherstash/stack/schema' |
| 8 | +import { describe, expect, it } from 'vitest' |
| 9 | +import { EncryptedQueryBuilderImpl } from '../src/query-builder' |
| 10 | +import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' |
| 11 | +import { |
| 12 | + createMockEncryptionClient, |
| 13 | + createMockSupabase, |
| 14 | + fakeEnvelope, |
| 15 | + operation, |
| 16 | +} from './helpers/supabase-mock' |
| 17 | + |
| 18 | +/** |
| 19 | + * Regression coverage for #626: the query builder's catch block used to hardcode |
| 20 | + * `encryptionError: undefined`, so the typed `EncryptedSupabaseError.encryptionError` |
| 21 | + * field was dead. The v2 tests pin that a genuine encryption failure now threads its |
| 22 | + * `EncryptionError` through the shared base `execute()` catch, while a plain |
| 23 | + * (non-encryption) throw leaves it unset. The v3 tests cover the dialect's own |
| 24 | + * `encryptionFailure` path, which synthesizes an `EncryptionError` for its two |
| 25 | + * query-term contract-violation cases (length mismatch, null envelope) that have |
| 26 | + * no operation failure to wrap. |
| 27 | + */ |
| 28 | + |
| 29 | +const usersV2 = encryptedTableV2('users', { |
| 30 | + email: encryptedColumn('email').freeTextSearch().equality(), |
| 31 | +}) |
| 32 | + |
| 33 | +const usersV3 = encryptedTable('users', { |
| 34 | + email: types.TextEq('email'), |
| 35 | +}) |
| 36 | + |
| 37 | +/** A chainable op that resolves to `{ failure }`, like a real failed operation. */ |
| 38 | +function failingOperation(failure: { type: string; message: string }) { |
| 39 | + const op = { |
| 40 | + withLockContext: () => op, |
| 41 | + audit: () => op, |
| 42 | + then: ( |
| 43 | + onfulfilled?: ((value: { failure: typeof failure }) => unknown) | null, |
| 44 | + onrejected?: ((reason: unknown) => unknown) | null, |
| 45 | + ) => Promise.resolve({ failure }).then(onfulfilled, onrejected), |
| 46 | + } |
| 47 | + return op |
| 48 | +} |
| 49 | + |
| 50 | +describe('EncryptedSupabaseError.encryptionError (#626)', () => { |
| 51 | + it('v2: threads the EncryptionError through on an encryption failure', async () => { |
| 52 | + const failure = { |
| 53 | + type: EncryptionErrorTypes.EncryptionError, |
| 54 | + message: 'zerokms unreachable', |
| 55 | + } |
| 56 | + const encryptionClient = createMockEncryptionClient() as unknown as Record< |
| 57 | + string, |
| 58 | + unknown |
| 59 | + > |
| 60 | + encryptionClient['encryptModel'] = () => failingOperation(failure) |
| 61 | + |
| 62 | + const { client: supabase } = createMockSupabase() |
| 63 | + const builder = new EncryptedQueryBuilderImpl( |
| 64 | + 'users', |
| 65 | + usersV2, |
| 66 | + encryptionClient as unknown as EncryptionClient, |
| 67 | + supabase, |
| 68 | + ) |
| 69 | + |
| 70 | + const { data, error } = await builder.insert({ email: 'ada@example.com' }) |
| 71 | + |
| 72 | + expect(data).toBeNull() |
| 73 | + expect(error).not.toBeNull() |
| 74 | + expect(error?.encryptionError).toEqual(failure) |
| 75 | + expect(error?.encryptionError?.type).toBe( |
| 76 | + EncryptionErrorTypes.EncryptionError, |
| 77 | + ) |
| 78 | + }) |
| 79 | + |
| 80 | + it('v2: leaves encryptionError unset on a plain (non-encryption) error', async () => { |
| 81 | + const encryptionClient = createMockEncryptionClient() |
| 82 | + const { client: supabase } = createMockSupabase() |
| 83 | + // Make the underlying supabase call throw a non-encryption error: an insert |
| 84 | + // with no encrypted columns skips encryption and goes straight to the wire. |
| 85 | + supabase.from = () => { |
| 86 | + throw new Error('PostgREST is down') |
| 87 | + } |
| 88 | + |
| 89 | + const builder = new EncryptedQueryBuilderImpl( |
| 90 | + 'users', |
| 91 | + usersV2, |
| 92 | + encryptionClient, |
| 93 | + supabase, |
| 94 | + ) |
| 95 | + |
| 96 | + const { data, error } = await builder.insert({ id: 1 } as never) |
| 97 | + |
| 98 | + expect(data).toBeNull() |
| 99 | + expect(error?.message).toContain('PostgREST is down') |
| 100 | + expect(error?.encryptionError).toBeUndefined() |
| 101 | + }) |
| 102 | + |
| 103 | + it('v3: synthesizes an EncryptionError for a query-term contract violation', async () => { |
| 104 | + // The v3 dialect's own `encryptionFailure` path has no operation failure to |
| 105 | + // wrap for its contract-violation cases, so it synthesizes an EncryptionError. |
| 106 | + // Drive it directly: a two-element `in` list whose bulkEncrypt returns one |
| 107 | + // term trips the length-mismatch check. (The base `execute()` threading is |
| 108 | + // already covered by the v2 test above; overriding `encryptModel` here would |
| 109 | + // only re-run that shared path, not this v3-specific branch.) |
| 110 | + const encryptionClient = createMockEncryptionClient() as unknown as { |
| 111 | + bulkEncrypt: (...args: unknown[]) => unknown |
| 112 | + } |
| 113 | + encryptionClient.bulkEncrypt = () => |
| 114 | + operation([{ data: fakeEnvelope('ada', 'email') }]) |
| 115 | + |
| 116 | + const { client: supabase } = createMockSupabase() |
| 117 | + const { error, status } = await new EncryptedQueryBuilderV3Impl( |
| 118 | + 'users', |
| 119 | + usersV3, |
| 120 | + encryptionClient as unknown as EncryptionClient, |
| 121 | + supabase, |
| 122 | + ['id', 'email'], |
| 123 | + ) |
| 124 | + .select('id') |
| 125 | + .in('email', ['ada@example.com', 'grace@example.com']) |
| 126 | + |
| 127 | + expect(status).toBe(500) |
| 128 | + expect(error?.encryptionError?.type).toBe( |
| 129 | + EncryptionErrorTypes.EncryptionError, |
| 130 | + ) |
| 131 | + expect(error?.encryptionError?.message).toMatch( |
| 132 | + /1 term(s)? for 2 value(s)?/, |
| 133 | + ) |
| 134 | + }) |
| 135 | + |
| 136 | + it('v3: synthesizes an EncryptionError for a null-envelope contract violation', async () => { |
| 137 | + // The other no-cause `encryptionFailure` path: a length-matched bulk response |
| 138 | + // whose position 0 is a null envelope. Same synthesized-EncryptionError branch |
| 139 | + // as the length-mismatch case above, reached from a different call site. |
| 140 | + const encryptionClient = createMockEncryptionClient() as unknown as { |
| 141 | + bulkEncrypt: (...args: unknown[]) => unknown |
| 142 | + } |
| 143 | + encryptionClient.bulkEncrypt = () => |
| 144 | + operation([{ data: null }, { data: fakeEnvelope('grace', 'email') }]) |
| 145 | + |
| 146 | + const { client: supabase } = createMockSupabase() |
| 147 | + const { error, status } = await new EncryptedQueryBuilderV3Impl( |
| 148 | + 'users', |
| 149 | + usersV3, |
| 150 | + encryptionClient as unknown as EncryptionClient, |
| 151 | + supabase, |
| 152 | + ['id', 'email'], |
| 153 | + ) |
| 154 | + .select('id') |
| 155 | + .in('email', ['ada@example.com', 'grace@example.com']) |
| 156 | + |
| 157 | + expect(status).toBe(500) |
| 158 | + expect(error?.encryptionError?.type).toBe( |
| 159 | + EncryptionErrorTypes.EncryptionError, |
| 160 | + ) |
| 161 | + expect(error?.encryptionError?.message).toMatch( |
| 162 | + /null envelope at position 0/, |
| 163 | + ) |
| 164 | + }) |
| 165 | +}) |
0 commit comments