Skip to content

Commit 748e4ff

Browse files
authored
Merge pull request #634 from cipherstash/fix/supabase-v2-encryption-error
fix(supabase): populate EncryptedSupabaseError.encryptionError (#626)
2 parents 7ca1fb9 + 663cfe9 commit 748e4ff

4 files changed

Lines changed: 198 additions & 5 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@cipherstash/stack-supabase': patch
3+
---
4+
5+
Populate `EncryptedSupabaseError.encryptionError` on encryption failures (#626).
6+
The query builder's catch block previously hardcoded `encryptionError: undefined`,
7+
so the typed field was always empty and callers had to detect encryption failures
8+
indirectly (via `status`/`statusText` or `.throwOnError()`). It now threads the
9+
underlying `EncryptionError` through — for both the v2 and v3 dialects — when the
10+
failure originates in an encrypt/decrypt step, and leaves it unset for plain
11+
PostgREST/API errors.
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
})

packages/stack-supabase/src/query-builder-v3.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
} from '@cipherstash/stack/adapter-kit'
66
import type { EncryptionClient } from '@cipherstash/stack/encryption'
77
import type { AnyV3Table } from '@cipherstash/stack/eql/v3'
8+
import {
9+
type EncryptionError,
10+
EncryptionErrorTypes,
11+
} from '@cipherstash/stack/errors'
812
import type {
913
ColumnSchema,
1014
EncryptedTable,
@@ -417,13 +421,17 @@ export class EncryptedQueryBuilderV3Impl<
417421
return column
418422
}
419423

420-
private encryptionFailure(message: string, cause?: unknown): never {
424+
private encryptionFailure(message: string, cause?: EncryptionError): never {
421425
logger.error(
422426
`Supabase: failed to encrypt query terms for table "${this.tableName}"`,
423427
)
428+
// Most callers pass the operation's own `EncryptionError`; the contract-
429+
// violation cases (bulk length mismatch, null envelope) have none, so
430+
// synthesize one — a broken query encryption is still an encryption failure,
431+
// and callers branch on `error.encryptionError` regardless.
424432
throw new EncryptionFailedError(
425433
`Failed to encrypt query terms: ${message}`,
426-
cause,
434+
cause ?? { type: EncryptionErrorTypes.EncryptionError, message },
427435
)
428436
}
429437

packages/stack-supabase/src/query-builder.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
modelToEncryptedPgComposites,
77
} from '@cipherstash/stack/adapter-kit'
88
import type { EncryptionClient } from '@cipherstash/stack/encryption'
9+
import type { EncryptionError } from '@cipherstash/stack/errors'
910
import type { LockContext } from '@cipherstash/stack/identity'
1011
import type {
1112
EncryptedTable,
@@ -419,9 +420,17 @@ export class EncryptedQueryBuilderImpl<
419420
`Supabase encrypted query failed on table "${this.tableName}": ${message}`,
420421
)
421422

423+
// A failure inside any of the encrypt/decrypt steps above is thrown as an
424+
// `EncryptionFailedError` wrapping the operation's `EncryptionError` (or, in
425+
// the v3 dialect, a synthesized one for its contract-violation cases).
426+
// Thread it through so callers can branch on `error.encryptionError`; a plain
427+
// PostgREST/API error is not an `EncryptionFailedError` and leaves it unset.
422428
const error: EncryptedSupabaseError = {
423429
message,
424-
encryptionError: undefined,
430+
encryptionError:
431+
err instanceof EncryptionFailedError
432+
? err.encryptionError
433+
: undefined,
425434
}
426435

427436
if (this.shouldThrowOnError) {
@@ -1599,9 +1608,9 @@ type RawSupabaseResult = {
15991608
}
16001609

16011610
export class EncryptionFailedError extends Error {
1602-
public encryptionError: unknown
1611+
public encryptionError: EncryptionError
16031612

1604-
constructor(message: string, encryptionError: unknown) {
1613+
constructor(message: string, encryptionError: EncryptionError) {
16051614
super(message)
16061615
this.name = 'EncryptionFailedError'
16071616
this.encryptionError = encryptionError

0 commit comments

Comments
 (0)