Skip to content

Commit 75bc15a

Browse files
antonisclaude
andcommitted
fix(core): Guard nullish response in supabase PostgREST handler
The `.then()` success handler in `instrumentPostgRESTFilterBuilder` accessed `res.error` without a null guard, causing a crash when `res` is undefined (observed in React Native). This adds a guard matching the pattern already used in `instrumentAuthOperation`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 94534e6 commit 75bc15a

File tree

2 files changed

+185
-1
lines changed

2 files changed

+185
-1
lines changed

packages/core/src/integrations/supabase.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte
403403
span.end();
404404
}
405405

406-
if (res.error) {
406+
if (res && res.error) {
407407
const err = new Error(res.error.message) as SupabaseError;
408408
if (res.error.code) {
409409
err.code = res.error.code;
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import * as breadcrumbModule from '../../../src/breadcrumbs';
3+
import * as exportsModule from '../../../src/exports';
4+
import {
5+
DB_OPERATIONS_TO_INSTRUMENT,
6+
extractOperation,
7+
instrumentSupabaseClient,
8+
translateFiltersIntoMethods,
9+
} from '../../../src/integrations/supabase';
10+
import type {
11+
PostgRESTFilterBuilder,
12+
PostgRESTQueryBuilder,
13+
SupabaseClientInstance,
14+
} from '../../../src/integrations/supabase';
15+
16+
// Mock tracing to avoid needing full SDK setup
17+
vi.mock('../../../src/tracing', () => ({
18+
startSpan: (_opts: any, cb: (span: any) => any) => {
19+
const mockSpan = {
20+
setStatus: vi.fn(),
21+
end: vi.fn(),
22+
};
23+
return cb(mockSpan);
24+
},
25+
setHttpStatus: vi.fn(),
26+
SPAN_STATUS_OK: 1,
27+
SPAN_STATUS_ERROR: 2,
28+
}));
29+
30+
describe('Supabase Integration', () => {
31+
describe('extractOperation', () => {
32+
it('returns select for GET', () => {
33+
expect(extractOperation('GET')).toBe('select');
34+
});
35+
36+
it('returns insert for POST without resolution header', () => {
37+
expect(extractOperation('POST')).toBe('insert');
38+
});
39+
40+
it('returns upsert for POST with resolution header', () => {
41+
expect(extractOperation('POST', { Prefer: 'resolution=merge-duplicates' })).toBe('upsert');
42+
});
43+
44+
it('returns update for PATCH', () => {
45+
expect(extractOperation('PATCH')).toBe('update');
46+
});
47+
48+
it('returns delete for DELETE', () => {
49+
expect(extractOperation('DELETE')).toBe('delete');
50+
});
51+
});
52+
53+
describe('translateFiltersIntoMethods', () => {
54+
it('returns select(*) for wildcard', () => {
55+
expect(translateFiltersIntoMethods('select', '*')).toBe('select(*)');
56+
});
57+
58+
it('returns select with columns', () => {
59+
expect(translateFiltersIntoMethods('select', 'id,name')).toBe('select(id,name)');
60+
});
61+
62+
it('translates eq filter', () => {
63+
expect(translateFiltersIntoMethods('id', 'eq.123')).toBe('eq(id, 123)');
64+
});
65+
});
66+
67+
describe('instrumentPostgRESTFilterBuilder - nullish response handling', () => {
68+
let captureExceptionSpy: ReturnType<typeof vi.spyOn>;
69+
let addBreadcrumbSpy: ReturnType<typeof vi.spyOn>;
70+
71+
beforeEach(() => {
72+
captureExceptionSpy = vi.spyOn(exportsModule, 'captureException').mockImplementation(() => '');
73+
addBreadcrumbSpy = vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {});
74+
});
75+
76+
afterEach(() => {
77+
vi.restoreAllMocks();
78+
});
79+
80+
function createMockSupabaseClient(resolveWith: unknown): unknown {
81+
// Create a PostgRESTFilterBuilder-like class
82+
class MockPostgRESTFilterBuilder {
83+
method = 'GET';
84+
headers: Record<string, string> = { 'X-Client-Info': 'supabase-js/2.0.0' };
85+
url = new URL('https://example.supabase.co/rest/v1/todos');
86+
schema = 'public';
87+
body = undefined;
88+
89+
then(onfulfilled?: (value: any) => any, onrejected?: (reason: any) => any): Promise<any> {
90+
return Promise.resolve(resolveWith).then(onfulfilled, onrejected);
91+
}
92+
}
93+
94+
class MockPostgRESTQueryBuilder {
95+
select() {
96+
return new MockPostgRESTFilterBuilder();
97+
}
98+
insert() {
99+
return new MockPostgRESTFilterBuilder();
100+
}
101+
upsert() {
102+
return new MockPostgRESTFilterBuilder();
103+
}
104+
update() {
105+
return new MockPostgRESTFilterBuilder();
106+
}
107+
delete() {
108+
return new MockPostgRESTFilterBuilder();
109+
}
110+
}
111+
112+
// Create a mock SupabaseClient constructor
113+
class MockSupabaseClient {
114+
auth = {
115+
admin: {} as any,
116+
} as SupabaseClientInstance['auth'];
117+
118+
from(_table: string): PostgRESTQueryBuilder {
119+
return new MockPostgRESTQueryBuilder() as unknown as PostgRESTQueryBuilder;
120+
}
121+
}
122+
123+
return new MockSupabaseClient();
124+
}
125+
126+
it('handles undefined response without throwing', async () => {
127+
const client = createMockSupabaseClient(undefined);
128+
instrumentSupabaseClient(client);
129+
130+
const builder = (client as any).from('todos');
131+
const result = builder.select('*');
132+
133+
// This should not throw even though the response is undefined
134+
const res = await result;
135+
expect(res).toBeUndefined();
136+
});
137+
138+
it('handles null response without throwing', async () => {
139+
const client = createMockSupabaseClient(null);
140+
instrumentSupabaseClient(client);
141+
142+
const builder = (client as any).from('todos');
143+
const result = builder.select('*');
144+
145+
const res = await result;
146+
expect(res).toBeNull();
147+
});
148+
149+
it('still adds breadcrumb when response is undefined', async () => {
150+
const client = createMockSupabaseClient(undefined);
151+
instrumentSupabaseClient(client);
152+
153+
const builder = (client as any).from('todos');
154+
await builder.select('*');
155+
156+
expect(addBreadcrumbSpy).toHaveBeenCalledWith(
157+
expect.objectContaining({
158+
type: 'supabase',
159+
category: 'db.select',
160+
}),
161+
);
162+
});
163+
164+
it('does not capture exception when response is undefined', async () => {
165+
const client = createMockSupabaseClient(undefined);
166+
instrumentSupabaseClient(client);
167+
168+
const builder = (client as any).from('todos');
169+
await builder.select('*');
170+
171+
expect(captureExceptionSpy).not.toHaveBeenCalled();
172+
});
173+
174+
it('still captures error when response has error', async () => {
175+
const client = createMockSupabaseClient({ status: 400, error: { message: 'Bad request', code: '400' } });
176+
instrumentSupabaseClient(client);
177+
178+
const builder = (client as any).from('todos');
179+
await builder.select('*');
180+
181+
expect(captureExceptionSpy).toHaveBeenCalled();
182+
});
183+
});
184+
});

0 commit comments

Comments
 (0)