Skip to content

Commit 5ec3076

Browse files
fix: health endpoint crashes when Supabase env vars are missing (#36)
Co-authored-by: Ona <no-reply@ona.com>
1 parent 89bb740 commit 5ec3076

3 files changed

Lines changed: 180 additions & 0 deletions

File tree

.agents/conventions.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,29 @@ The `updateSession` function in `src/lib/supabase/proxy.ts` creates a server cli
117117
that reads cookies from the request and writes refreshed cookies to the response.
118118
It calls `supabase.auth.getUser()` to trigger the refresh.
119119

120+
## Environment Variable Guards
121+
122+
Route handlers and server utilities that use Supabase must guard against missing
123+
env vars before calling `createClient()`. Without the guard, `createServerClient`
124+
receives `undefined` and throws, which can crash the route or produce misleading
125+
error responses (e.g., health endpoint reporting "down" instead of "not configured").
126+
127+
The proxy already does this — route handlers must follow the same pattern:
128+
129+
```typescript
130+
if (
131+
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
132+
!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
133+
) {
134+
// Return a graceful response instead of crashing
135+
return NextResponse.json({ status: "ok", db: { connected: false, message: "not configured" } });
136+
}
137+
```
138+
139+
Apply this guard in any route handler that calls `createClient()` and must remain
140+
functional even when Supabase is not yet configured (e.g., health checks, public
141+
status endpoints).
142+
120143
## Component Patterns
121144

122145
### Server Component (default)

src/app/api/health/route.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
// Mock next/headers cookies before importing the route
4+
vi.mock("next/headers", () => ({
5+
cookies: vi.fn().mockResolvedValue({
6+
getAll: () => [],
7+
set: vi.fn(),
8+
}),
9+
}));
10+
11+
// Mock the Supabase server client
12+
vi.mock("@/lib/supabase/server", () => ({
13+
createClient: vi.fn(),
14+
}));
15+
16+
import { GET } from "./route";
17+
import { createClient } from "@/lib/supabase/server";
18+
19+
const mockedCreateClient = vi.mocked(createClient);
20+
21+
beforeEach(() => {
22+
vi.restoreAllMocks();
23+
});
24+
25+
describe("GET /api/health", () => {
26+
it("returns not-configured when NEXT_PUBLIC_SUPABASE_URL is missing", async () => {
27+
const original = process.env.NEXT_PUBLIC_SUPABASE_URL;
28+
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
29+
30+
const response = await GET();
31+
const body = await response.json();
32+
33+
expect(body.status).toBe("ok");
34+
expect(body.db.connected).toBe(false);
35+
expect(body.db.message).toBe("not configured");
36+
expect(mockedCreateClient).not.toHaveBeenCalled();
37+
38+
// Restore
39+
if (original !== undefined) process.env.NEXT_PUBLIC_SUPABASE_URL = original;
40+
});
41+
42+
it("returns not-configured when NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY is missing", async () => {
43+
const originalUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
44+
const originalKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
45+
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://example.supabase.co";
46+
delete process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
47+
48+
const response = await GET();
49+
const body = await response.json();
50+
51+
expect(body.status).toBe("ok");
52+
expect(body.db.connected).toBe(false);
53+
expect(body.db.message).toBe("not configured");
54+
expect(mockedCreateClient).not.toHaveBeenCalled();
55+
56+
// Restore
57+
if (originalUrl !== undefined) {
58+
process.env.NEXT_PUBLIC_SUPABASE_URL = originalUrl;
59+
} else {
60+
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
61+
}
62+
if (originalKey !== undefined) {
63+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = originalKey;
64+
}
65+
});
66+
67+
it("returns ok when Supabase query succeeds", async () => {
68+
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://example.supabase.co";
69+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = "test-key";
70+
71+
const mockMaybeSingle = vi.fn().mockResolvedValue({ data: null, error: null });
72+
const mockLimit = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle });
73+
const mockSelect = vi.fn().mockReturnValue({ limit: mockLimit });
74+
const mockFrom = vi.fn().mockReturnValue({ select: mockSelect });
75+
76+
mockedCreateClient.mockResolvedValue({ from: mockFrom } as unknown as Awaited<
77+
ReturnType<typeof createClient>
78+
>);
79+
80+
const response = await GET();
81+
const body = await response.json();
82+
83+
expect(body.status).toBe("ok");
84+
expect(body.db.connected).toBe(true);
85+
expect(body.db.latency_ms).toBeGreaterThanOrEqual(0);
86+
});
87+
88+
it("returns ok with connected when table does not exist", async () => {
89+
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://example.supabase.co";
90+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = "test-key";
91+
92+
const mockMaybeSingle = vi.fn().mockResolvedValue({
93+
data: null,
94+
error: { message: 'relation "_health_check" does not exist' },
95+
});
96+
const mockLimit = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle });
97+
const mockSelect = vi.fn().mockReturnValue({ limit: mockLimit });
98+
const mockFrom = vi.fn().mockReturnValue({ select: mockSelect });
99+
100+
mockedCreateClient.mockResolvedValue({ from: mockFrom } as unknown as Awaited<
101+
ReturnType<typeof createClient>
102+
>);
103+
104+
const response = await GET();
105+
const body = await response.json();
106+
107+
expect(body.status).toBe("ok");
108+
expect(body.db.connected).toBe(true);
109+
});
110+
111+
it("returns down when Supabase client throws", async () => {
112+
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://example.supabase.co";
113+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = "test-key";
114+
115+
mockedCreateClient.mockRejectedValue(new Error("Connection refused"));
116+
117+
const response = await GET();
118+
const body = await response.json();
119+
120+
expect(body.status).toBe("down");
121+
expect(body.db.connected).toBe(false);
122+
});
123+
124+
it("returns degraded for non-connection Supabase errors", async () => {
125+
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://example.supabase.co";
126+
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = "test-key";
127+
128+
const mockMaybeSingle = vi.fn().mockResolvedValue({
129+
data: null,
130+
error: { message: "permission denied for table _health_check" },
131+
});
132+
const mockLimit = vi.fn().mockReturnValue({ maybeSingle: mockMaybeSingle });
133+
const mockSelect = vi.fn().mockReturnValue({ limit: mockLimit });
134+
const mockFrom = vi.fn().mockReturnValue({ select: mockSelect });
135+
136+
mockedCreateClient.mockResolvedValue({ from: mockFrom } as unknown as Awaited<
137+
ReturnType<typeof createClient>
138+
>);
139+
140+
const response = await GET();
141+
const body = await response.json();
142+
143+
expect(body.status).toBe("ok");
144+
expect(body.db.connected).toBe(true);
145+
});
146+
});

src/app/api/health/route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ export async function GET() {
66
let dbStatus = "ok";
77
let dbLatency = 0;
88

9+
if (
10+
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
11+
!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
12+
) {
13+
return NextResponse.json({
14+
status: "ok",
15+
db: { connected: false, latency_ms: 0, message: "not configured" },
16+
timestamp: new Date().toISOString(),
17+
});
18+
}
19+
920
try {
1021
const supabase = await createClient();
1122
const { error } = await supabase

0 commit comments

Comments
 (0)