Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/supabase-encryption-error.md
Original file line number Diff line number Diff line change
@@ -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.
165 changes: 165 additions & 0 deletions packages/stack-supabase/__tests__/supabase-encryption-error.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
)
})
})
12 changes: 10 additions & 2 deletions packages/stack-supabase/src/query-builder-v3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
)
}

Expand Down
15 changes: 12 additions & 3 deletions packages/stack-supabase/src/query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Loading