Skip to content

Commit 09a6750

Browse files
mandariniclaude
andauthored
test: add E2E tests for all four adapters against a local Supabase stack (#99)
* test: add E2E tests for all four adapters against a local Supabase stack Adds an e2e vitest project (SDK-1143) covering what the mocked unit tests cannot: real GoTrue-issued JWTs verified against the live JWKS endpoint, real Supabase client operations via supabaseAdmin, resolveEnv() reading process.env, and imports from dist/ so packaging regressions fail here. One scenario set (auth + data access + isolation) runs over real HTTP against minimal Hono, H3, Elysia, and NestJS apps. Elysia runs behind a node:http server (srvx) so CI needs no Bun. A separate E2E workflow starts the local stack with the Supabase CLI, builds, and runs the suite. * test: grant explicit table privileges in the e2e notes migration Newer Supabase stacks make new tables private by default — the API roles (anon/authenticated/service_role) no longer receive DML grants on table creation. CI installs the latest CLI, so all supabaseAdmin queries failed with "permission denied for table notes" while JWT scenarios passed. Reproduced locally on CLI 2.109.1; explicit grants fix it on both old and new stacks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: align NestJS missing-body handling and cover it in the scenarios The NestJS app silently inserted an empty note when the body was missing, while the other three adapters returned 400 — and no scenario exercised those 400 branches. NestJS now throws BadRequestException like the rest, and a shared missing-body scenario keeps all four aligned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: cover forged JWTs, the RLS-scoped client, and optional-auth rejection Closes the three gaps from PR review: the garbage-token scenario failed at header decode without ever reaching signature verification, ctx.supabase (the RLS-scoped client) was never exercised, and nothing pinned that a present-but-invalid token on an optional route is rejected rather than downgraded to anonymous. - Mint a well-formed JWT with the live JWKS kid but a wrong signing key in global setup; every adapter must 401 it — proving signature verification end-to-end, not just structure checks. - Add GET /my-notes reading through ctx.supabase with no WHERE clause, backed by a user_id = auth.uid() select policy — proving the caller's token reaches PostgREST and Postgres RLS scopes the rows. - Assert GET /me-optional with an invalid token → 401. 10 → 14 scenarios per adapter (56 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: add core-wrapper app, admin-bypass proof, and sign-in readability Addresses PR review comments: - New fifth app on the core withSupabase(config, handler) fetch wrapper — the exact programming model Supabase Edge Functions deploy — running the full scenario set behind node:http. A real Deno runtime e2e via `supabase functions serve` is tracked in SDK-1280. - New GET /all-notes route (admin client, no filter) + scenario: user2's request sees user1's rows through supabaseAdmin, directly proving the admin client is not scoped to the caller's identity. - Replace the `;({ data, error } = ...)` destructuring-reassignment in the sign-in helper with a plain result variable. 15 scenarios × 5 apps (75 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 35ceae2 commit 09a6750

30 files changed

Lines changed: 1171 additions & 38 deletions

.github/workflows/e2e.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: E2E
2+
3+
# Separate from the main CI workflow because this one needs Docker (local
4+
# Supabase stack). The suite tests dist/, real JWKS, and real HTTP servers —
5+
# see e2e/README.md.
6+
on:
7+
push:
8+
branches: [main]
9+
pull_request:
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
e2e:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
19+
with:
20+
persist-credentials: false
21+
22+
- uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5
23+
24+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
25+
with:
26+
node-version: 22
27+
28+
- uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3.0.0
29+
with:
30+
version: latest
31+
32+
- run: pnpm install --frozen-lockfile
33+
34+
# The E2E suite imports from dist/, not src/ — build first.
35+
- run: pnpm build
36+
37+
- name: Typecheck E2E suite
38+
run: pnpm typecheck:e2e
39+
40+
- name: Start local Supabase stack
41+
run: supabase start
42+
working-directory: e2e
43+
44+
- run: pnpm gen:env
45+
46+
- run: pnpm test:e2e
47+
48+
- name: Stop local Supabase stack
49+
if: always()
50+
run: supabase stop --no-backup
51+
working-directory: e2e

CONTRIBUTING.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,30 @@ Format all code using Prettier:
7070
pnpm format
7171
```
7272

73+
## Testing
74+
75+
Run the unit and integration tests (no external dependencies needed):
76+
77+
```bash
78+
pnpm test
79+
```
80+
81+
### End-to-end tests
82+
83+
The E2E suite (`e2e/`) runs real JWT issuance, real JWKS validation, and real
84+
Supabase client operations against a local Supabase stack, across all four
85+
adapters. It imports the library from `dist/`, so build first:
86+
87+
```bash
88+
pnpm build
89+
cd e2e && supabase start && cd .. # requires Docker
90+
pnpm gen:env
91+
pnpm test:e2e
92+
```
93+
94+
See [`e2e/README.md`](e2e/README.md) for details. CI runs this suite in a
95+
separate workflow (`.github/workflows/e2e.yml`).
96+
7397
## Submitting Changes
7498

7599
### Commit Messages

e2e/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# E2E tests
2+
3+
End-to-end coverage for `@supabase/server`: real GoTrue-issued JWTs, real JWKS
4+
validation over HTTP, real Supabase client operations — across all four
5+
adapters (Hono, H3, Elysia, NestJS) plus the core `withSupabase` fetch
6+
wrapper, the programming model Supabase Edge Functions use.
7+
8+
Unlike the unit/integration tests (mocked env, `jwks: null`), this suite:
9+
10+
- imports the library from `dist/`, not `src/`, so packaging regressions fail here
11+
- reads config from `process.env` via `resolveEnv()` — no mocked env objects
12+
- verifies JWTs against the local stack's live JWKS endpoint — including
13+
rejecting a forged token (real `kid`, wrong signing key), so signature
14+
verification itself is exercised, not just structure checks
15+
- covers both context clients: `supabaseAdmin` (app-layer scoping) and the
16+
user-scoped `supabase` client, where the caller's JWT travels to PostgREST
17+
and a Postgres RLS policy scopes the rows
18+
- runs each adapter app on a real HTTP server and asserts over `fetch`
19+
20+
## Running locally
21+
22+
```sh
23+
pnpm build # e2e imports from dist/
24+
cd e2e && supabase start # local stack (Docker) on ports 5433x
25+
cd .. && pnpm gen:env # writes e2e/.env from `supabase status`
26+
pnpm test:e2e
27+
```
28+
29+
Run a single adapter with `pnpm test:e2e h3`.
30+
31+
## Layout
32+
33+
- `supabase/` — local stack config + `notes` table migration
34+
- `apps/<adapter>/app.ts` — minimal app per adapter, identical route surface:
35+
`GET /health` (public), `GET /me` (user), `GET /me-optional` (user or none),
36+
`GET|POST /notes` (user, admin client scoped by `userClaims.id`),
37+
`GET /my-notes` (user, RLS-scoped client — no WHERE clause),
38+
`GET /all-notes` (user, admin client with no filter — proves the admin
39+
client is not scoped to the caller)
40+
- `apps/core/app.ts` — same surface on the core `withSupabase(config, handler)`
41+
fetch wrapper (no adapter) — what an Edge Function deploys. A real Deno
42+
`supabase functions serve` e2e is tracked as a follow-up issue.
43+
- `scenarios.ts` — the single scenario set run against every adapter
44+
- `setup/global-setup.ts` — checks the stack is up, signs in two test users,
45+
provides their tokens to the tests
46+
- `scripts/gen-env.sh` — writes `.env` (gitignored) from the running stack
47+
- `scripts/get-token.ts` — prints a real user JWT for manual curl testing:
48+
`TOKEN=$(node e2e/scripts/get-token.ts)` (Node 22.18+)
49+
50+
Elysia is Bun-first, but its `app.handle` is a plain fetch handler, so the app
51+
runs behind a `node:http` server (via `srvx`) and CI needs no Bun.

e2e/apps/core/app.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Minimal app on the CORE withSupabase(config, handler) fetch wrapper — no
2+
// framework adapter. This is the exact programming model Supabase Edge
3+
// Functions use (`Deno.serve(withSupabase(...))`); here the same handler runs
4+
// behind node:http. A real Deno `supabase functions serve` e2e is tracked
5+
// separately.
6+
//
7+
// The core wrapper has no router, so routes are dispatched on pathname and
8+
// each auth mode gets its own wrapped handler.
9+
import { withSupabase } from '../../../dist/index.mjs'
10+
import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts'
11+
import { startFetchServer } from '../../setup/serve.ts'
12+
13+
const userHandler = withSupabase({ auth: 'user' }, async (req, ctx) => {
14+
const { pathname } = new URL(req.url)
15+
const { supabase, supabaseAdmin, userClaims } = ctx
16+
17+
if (pathname === '/me') return Response.json({ userClaims })
18+
if (pathname === '/my-notes') {
19+
return Response.json(await listOwnNotes(supabase))
20+
}
21+
if (pathname === '/all-notes') {
22+
return Response.json(await listAllNotes(supabaseAdmin))
23+
}
24+
if (pathname === '/notes' && req.method === 'GET') {
25+
return Response.json(await listNotes(supabaseAdmin, userClaims!.id))
26+
}
27+
if (pathname === '/notes' && req.method === 'POST') {
28+
const { body } = (await req.json()) as { body?: string }
29+
if (!body) {
30+
return Response.json({ error: 'body required' }, { status: 400 })
31+
}
32+
const note = await insertNote(supabaseAdmin, userClaims!.id, body)
33+
return Response.json(note, { status: 201 })
34+
}
35+
return Response.json({ error: 'not found' }, { status: 404 })
36+
})
37+
38+
const optionalHandler = withSupabase(
39+
{ auth: ['user', 'none'] },
40+
async (_req, ctx) => Response.json({ userClaims: ctx.userClaims }),
41+
)
42+
43+
function fetchHandler(req: Request): Response | Promise<Response> {
44+
const { pathname } = new URL(req.url)
45+
if (pathname === '/health') return Response.json({ status: 'ok' })
46+
if (pathname === '/me-optional') return optionalHandler(req)
47+
return userHandler(req)
48+
}
49+
50+
export function start(port: number) {
51+
return startFetchServer(fetchHandler, port)
52+
}

e2e/apps/elysia/app.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Minimal Elysia app exercising @supabase/server from dist/ (not src/), with
2+
// config read from process.env via resolveEnv() — no mocked env anywhere.
3+
//
4+
// Elysia is Bun-first, but `app.handle` is a plain fetch handler, so the app
5+
// runs behind a node:http server (see setup/serve.ts) and CI needs no Bun.
6+
import { Elysia } from 'elysia'
7+
8+
import { withSupabase } from '../../../dist/adapters/elysia/index.mjs'
9+
import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts'
10+
import { startFetchServer } from '../../setup/serve.ts'
11+
12+
const app = new Elysia()
13+
.get('/health', () => ({ status: 'ok' }))
14+
.use(
15+
new Elysia()
16+
.use(withSupabase({ auth: ['user', 'none'] }))
17+
.get('/me-optional', ({ supabaseContext }) => ({
18+
userClaims: supabaseContext.userClaims,
19+
})),
20+
)
21+
.use(
22+
new Elysia()
23+
.use(withSupabase({ auth: 'user' }))
24+
.get('/me', ({ supabaseContext }) => ({
25+
userClaims: supabaseContext.userClaims,
26+
}))
27+
.get('/notes', ({ supabaseContext }) => {
28+
const { supabaseAdmin, userClaims } = supabaseContext
29+
return listNotes(supabaseAdmin, userClaims!.id)
30+
})
31+
// User-scoped client: RLS does the scoping, no WHERE clause.
32+
.get('/my-notes', ({ supabaseContext }) =>
33+
listOwnNotes(supabaseContext.supabase),
34+
)
35+
// Admin client, no filter: sees every user's rows regardless of caller.
36+
.get('/all-notes', ({ supabaseContext }) =>
37+
listAllNotes(supabaseContext.supabaseAdmin),
38+
)
39+
.post('/notes', async ({ supabaseContext, body, set }) => {
40+
const { supabaseAdmin, userClaims } = supabaseContext
41+
const payload = body as { body?: string } | null
42+
if (!payload?.body) {
43+
set.status = 400
44+
return { error: 'body required' }
45+
}
46+
const note = await insertNote(
47+
supabaseAdmin,
48+
userClaims!.id,
49+
payload.body,
50+
)
51+
set.status = 201
52+
return note
53+
}),
54+
)
55+
56+
export function start(port: number) {
57+
return startFetchServer((req) => app.handle(req), port)
58+
}

e2e/apps/h3/app.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Minimal H3 app exercising @supabase/server from dist/ (not src/), with
2+
// config read from process.env via resolveEnv() — no mocked env anywhere.
3+
import { H3, defineHandler } from 'h3'
4+
5+
import { withSupabase } from '../../../dist/adapters/h3/index.mjs'
6+
import type { SupabaseContext } from '../../../dist/index.mjs'
7+
import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts'
8+
import { startFetchServer } from '../../setup/serve.ts'
9+
10+
declare module 'h3' {
11+
interface H3EventContext {
12+
supabaseContext: SupabaseContext
13+
}
14+
}
15+
16+
const requireUser = withSupabase({ auth: 'user' })
17+
const optionalUser = withSupabase({ auth: ['user', 'none'] })
18+
19+
const app = new H3()
20+
21+
app.get('/health', () => ({ status: 'ok' }))
22+
23+
app.get(
24+
'/me',
25+
defineHandler({
26+
middleware: [requireUser],
27+
handler: (event) => ({
28+
userClaims: event.context.supabaseContext.userClaims,
29+
}),
30+
}),
31+
)
32+
33+
app.get(
34+
'/me-optional',
35+
defineHandler({
36+
middleware: [optionalUser],
37+
handler: (event) => ({
38+
userClaims: event.context.supabaseContext.userClaims,
39+
}),
40+
}),
41+
)
42+
43+
app.get(
44+
'/notes',
45+
defineHandler({
46+
middleware: [requireUser],
47+
handler: async (event) => {
48+
const { supabaseAdmin, userClaims } = event.context.supabaseContext
49+
return listNotes(supabaseAdmin, userClaims!.id)
50+
},
51+
}),
52+
)
53+
54+
// User-scoped client: RLS does the scoping, no WHERE clause.
55+
app.get(
56+
'/my-notes',
57+
defineHandler({
58+
middleware: [requireUser],
59+
handler: (event) => listOwnNotes(event.context.supabaseContext.supabase),
60+
}),
61+
)
62+
63+
// Admin client, no filter: sees every user's rows regardless of caller.
64+
app.get(
65+
'/all-notes',
66+
defineHandler({
67+
middleware: [requireUser],
68+
handler: (event) =>
69+
listAllNotes(event.context.supabaseContext.supabaseAdmin),
70+
}),
71+
)
72+
73+
app.post(
74+
'/notes',
75+
defineHandler({
76+
middleware: [requireUser],
77+
handler: async (event) => {
78+
const { supabaseAdmin, userClaims } = event.context.supabaseContext
79+
const { body } = (await event.req.json()) as { body?: string }
80+
if (!body) {
81+
return Response.json({ error: 'body required' }, { status: 400 })
82+
}
83+
const note = await insertNote(supabaseAdmin, userClaims!.id, body)
84+
return Response.json(note, { status: 201 })
85+
},
86+
}),
87+
)
88+
89+
export function start(port: number) {
90+
return startFetchServer(app.fetch, port)
91+
}

e2e/apps/hono/app.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Minimal Hono app exercising @supabase/server from dist/ (not src/), with
2+
// config read from process.env via resolveEnv() — no mocked env anywhere.
3+
import { Hono } from 'hono'
4+
5+
import { withSupabase } from '../../../dist/adapters/hono/index.mjs'
6+
import type { SupabaseContext } from '../../../dist/index.mjs'
7+
import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts'
8+
import { startFetchServer } from '../../setup/serve.ts'
9+
10+
type Env = { Variables: { supabaseContext: SupabaseContext } }
11+
12+
const app = new Hono<Env>()
13+
14+
app.get('/health', (c) => c.json({ status: 'ok' }))
15+
16+
app.use('/me', withSupabase({ auth: 'user' }))
17+
app.use('/me-optional', withSupabase({ auth: ['user', 'none'] }))
18+
app.use('/notes', withSupabase({ auth: 'user' }))
19+
app.use('/my-notes', withSupabase({ auth: 'user' }))
20+
app.use('/all-notes', withSupabase({ auth: 'user' }))
21+
22+
app.get('/me', (c) => c.json({ userClaims: c.var.supabaseContext.userClaims }))
23+
24+
app.get('/me-optional', (c) =>
25+
c.json({ userClaims: c.var.supabaseContext.userClaims }),
26+
)
27+
28+
app.get('/notes', async (c) => {
29+
const { supabaseAdmin, userClaims } = c.var.supabaseContext
30+
return c.json(await listNotes(supabaseAdmin, userClaims!.id))
31+
})
32+
33+
// User-scoped client: RLS does the scoping, no WHERE clause.
34+
app.get('/my-notes', async (c) =>
35+
c.json(await listOwnNotes(c.var.supabaseContext.supabase)),
36+
)
37+
38+
// Admin client, no filter: sees every user's rows regardless of caller.
39+
app.get('/all-notes', async (c) =>
40+
c.json(await listAllNotes(c.var.supabaseContext.supabaseAdmin)),
41+
)
42+
43+
app.post('/notes', async (c) => {
44+
const { supabaseAdmin, userClaims } = c.var.supabaseContext
45+
const { body } = await c.req.json<{ body?: string }>()
46+
if (!body) return c.json({ error: 'body required' }, 400)
47+
return c.json(await insertNote(supabaseAdmin, userClaims!.id, body), 201)
48+
})
49+
50+
export function start(port: number) {
51+
return startFetchServer(app.fetch, port)
52+
}

0 commit comments

Comments
 (0)