diff --git a/.changeset/supabase-encryption-error.md b/.changeset/supabase-encryption-error.md new file mode 100644 index 00000000..d83143e0 --- /dev/null +++ b/.changeset/supabase-encryption-error.md @@ -0,0 +1,11 @@ +--- +'@cipherstash/stack-supabase': patch +--- + +Populate `EncryptedSupabaseError.encryptionError` on encryption failures (#626). +The query builder's catch block previously hardcoded `encryptionError: undefined`, +so the typed field was always empty and callers had to detect encryption failures +indirectly (via `status`/`statusText` or `.throwOnError()`). It now threads the +underlying `EncryptionError` through — for both the v2 and v3 dialects — when the +failure originates in an encrypt/decrypt step, and leaves it unset for plain +PostgREST/API errors. diff --git a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts new file mode 100644 index 00000000..5154b8c1 --- /dev/null +++ b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts @@ -0,0 +1,165 @@ +import type { EncryptionClient } from '@cipherstash/stack/encryption' +import { encryptedTable, types } from '@cipherstash/stack/eql/v3' +import { EncryptionErrorTypes } from '@cipherstash/stack/errors' +import { + encryptedColumn, + encryptedTable as encryptedTableV2, +} from '@cipherstash/stack/schema' +import { describe, expect, it } from 'vitest' +import { EncryptedQueryBuilderImpl } from '../src/query-builder' +import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { + createMockEncryptionClient, + createMockSupabase, + fakeEnvelope, + operation, +} from './helpers/supabase-mock' + +/** + * Regression coverage for #626: the query builder's catch block used to hardcode + * `encryptionError: undefined`, so the typed `EncryptedSupabaseError.encryptionError` + * field was dead. The v2 tests pin that a genuine encryption failure now threads its + * `EncryptionError` through the shared base `execute()` catch, while a plain + * (non-encryption) throw leaves it unset. The v3 tests cover the dialect's own + * `encryptionFailure` path, which synthesizes an `EncryptionError` for its two + * query-term contract-violation cases (length mismatch, null envelope) that have + * no operation failure to wrap. + */ + +const usersV2 = encryptedTableV2('users', { + email: encryptedColumn('email').freeTextSearch().equality(), +}) + +const usersV3 = encryptedTable('users', { + email: types.TextEq('email'), +}) + +/** A chainable op that resolves to `{ failure }`, like a real failed operation. */ +function failingOperation(failure: { type: string; message: string }) { + const op = { + withLockContext: () => op, + audit: () => op, + then: ( + onfulfilled?: ((value: { failure: typeof failure }) => unknown) | null, + onrejected?: ((reason: unknown) => unknown) | null, + ) => Promise.resolve({ failure }).then(onfulfilled, onrejected), + } + return op +} + +describe('EncryptedSupabaseError.encryptionError (#626)', () => { + it('v2: threads the EncryptionError through on an encryption failure', async () => { + const failure = { + type: EncryptionErrorTypes.EncryptionError, + message: 'zerokms unreachable', + } + const encryptionClient = createMockEncryptionClient() as unknown as Record< + string, + unknown + > + encryptionClient['encryptModel'] = () => failingOperation(failure) + + const { client: supabase } = createMockSupabase() + const builder = new EncryptedQueryBuilderImpl( + 'users', + usersV2, + encryptionClient as unknown as EncryptionClient, + supabase, + ) + + const { data, error } = await builder.insert({ email: 'ada@example.com' }) + + expect(data).toBeNull() + expect(error).not.toBeNull() + expect(error?.encryptionError).toEqual(failure) + expect(error?.encryptionError?.type).toBe( + EncryptionErrorTypes.EncryptionError, + ) + }) + + it('v2: leaves encryptionError unset on a plain (non-encryption) error', async () => { + const encryptionClient = createMockEncryptionClient() + const { client: supabase } = createMockSupabase() + // Make the underlying supabase call throw a non-encryption error: an insert + // with no encrypted columns skips encryption and goes straight to the wire. + supabase.from = () => { + throw new Error('PostgREST is down') + } + + const builder = new EncryptedQueryBuilderImpl( + 'users', + usersV2, + encryptionClient, + supabase, + ) + + const { data, error } = await builder.insert({ id: 1 } as never) + + expect(data).toBeNull() + expect(error?.message).toContain('PostgREST is down') + expect(error?.encryptionError).toBeUndefined() + }) + + it('v3: synthesizes an EncryptionError for a query-term contract violation', async () => { + // The v3 dialect's own `encryptionFailure` path has no operation failure to + // wrap for its contract-violation cases, so it synthesizes an EncryptionError. + // Drive it directly: a two-element `in` list whose bulkEncrypt returns one + // term trips the length-mismatch check. (The base `execute()` threading is + // already covered by the v2 test above; overriding `encryptModel` here would + // only re-run that shared path, not this v3-specific branch.) + const encryptionClient = createMockEncryptionClient() as unknown as { + bulkEncrypt: (...args: unknown[]) => unknown + } + encryptionClient.bulkEncrypt = () => + operation([{ data: fakeEnvelope('ada', 'email') }]) + + const { client: supabase } = createMockSupabase() + const { error, status } = await new EncryptedQueryBuilderV3Impl( + 'users', + usersV3, + encryptionClient as unknown as EncryptionClient, + supabase, + ['id', 'email'], + ) + .select('id') + .in('email', ['ada@example.com', 'grace@example.com']) + + expect(status).toBe(500) + expect(error?.encryptionError?.type).toBe( + EncryptionErrorTypes.EncryptionError, + ) + expect(error?.encryptionError?.message).toMatch( + /1 term(s)? for 2 value(s)?/, + ) + }) + + it('v3: synthesizes an EncryptionError for a null-envelope contract violation', async () => { + // The other no-cause `encryptionFailure` path: a length-matched bulk response + // whose position 0 is a null envelope. Same synthesized-EncryptionError branch + // as the length-mismatch case above, reached from a different call site. + const encryptionClient = createMockEncryptionClient() as unknown as { + bulkEncrypt: (...args: unknown[]) => unknown + } + encryptionClient.bulkEncrypt = () => + operation([{ data: null }, { data: fakeEnvelope('grace', 'email') }]) + + const { client: supabase } = createMockSupabase() + const { error, status } = await new EncryptedQueryBuilderV3Impl( + 'users', + usersV3, + encryptionClient as unknown as EncryptionClient, + supabase, + ['id', 'email'], + ) + .select('id') + .in('email', ['ada@example.com', 'grace@example.com']) + + expect(status).toBe(500) + expect(error?.encryptionError?.type).toBe( + EncryptionErrorTypes.EncryptionError, + ) + expect(error?.encryptionError?.message).toMatch( + /null envelope at position 0/, + ) + }) +}) diff --git a/packages/stack-supabase/src/query-builder-v3.ts b/packages/stack-supabase/src/query-builder-v3.ts index b1bb131d..a84de692 100644 --- a/packages/stack-supabase/src/query-builder-v3.ts +++ b/packages/stack-supabase/src/query-builder-v3.ts @@ -5,6 +5,10 @@ import { } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { AnyV3Table } from '@cipherstash/stack/eql/v3' +import { + type EncryptionError, + EncryptionErrorTypes, +} from '@cipherstash/stack/errors' import type { ColumnSchema, EncryptedTable, @@ -417,13 +421,17 @@ export class EncryptedQueryBuilderV3Impl< return column } - private encryptionFailure(message: string, cause?: unknown): never { + private encryptionFailure(message: string, cause?: EncryptionError): never { logger.error( `Supabase: failed to encrypt query terms for table "${this.tableName}"`, ) + // Most callers pass the operation's own `EncryptionError`; the contract- + // violation cases (bulk length mismatch, null envelope) have none, so + // synthesize one — a broken query encryption is still an encryption failure, + // and callers branch on `error.encryptionError` regardless. throw new EncryptionFailedError( `Failed to encrypt query terms: ${message}`, - cause, + cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, ) } diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index 29b80903..57d0fc03 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -6,6 +6,7 @@ import { modelToEncryptedPgComposites, } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' +import type { EncryptionError } from '@cipherstash/stack/errors' import type { LockContext } from '@cipherstash/stack/identity' import type { EncryptedTable, @@ -419,9 +420,17 @@ export class EncryptedQueryBuilderImpl< `Supabase encrypted query failed on table "${this.tableName}": ${message}`, ) + // A failure inside any of the encrypt/decrypt steps above is thrown as an + // `EncryptionFailedError` wrapping the operation's `EncryptionError` (or, in + // the v3 dialect, a synthesized one for its contract-violation cases). + // Thread it through so callers can branch on `error.encryptionError`; a plain + // PostgREST/API error is not an `EncryptionFailedError` and leaves it unset. const error: EncryptedSupabaseError = { message, - encryptionError: undefined, + encryptionError: + err instanceof EncryptionFailedError + ? err.encryptionError + : undefined, } if (this.shouldThrowOnError) { @@ -1599,9 +1608,9 @@ type RawSupabaseResult = { } export class EncryptionFailedError extends Error { - public encryptionError: unknown + public encryptionError: EncryptionError - constructor(message: string, encryptionError: unknown) { + constructor(message: string, encryptionError: EncryptionError) { super(message) this.name = 'EncryptionFailedError' this.encryptionError = encryptionError