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
51 changes: 51 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: E2E

# Separate from the main CI workflow because this one needs Docker (local
# Supabase stack). The suite tests dist/, real JWKS, and real HTTP servers —
# see e2e/README.md.
on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22

- uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3.0.0
with:
version: latest

- run: pnpm install --frozen-lockfile

# The E2E suite imports from dist/, not src/ — build first.
- run: pnpm build

- name: Typecheck E2E suite
run: pnpm typecheck:e2e

- name: Start local Supabase stack
run: supabase start
working-directory: e2e

- run: pnpm gen:env

- run: pnpm test:e2e

- name: Stop local Supabase stack
if: always()
run: supabase stop --no-backup
working-directory: e2e
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,30 @@ Format all code using Prettier:
pnpm format
```

## Testing

Run the unit and integration tests (no external dependencies needed):

```bash
pnpm test
```

### End-to-end tests

The E2E suite (`e2e/`) runs real JWT issuance, real JWKS validation, and real
Supabase client operations against a local Supabase stack, across all four
adapters. It imports the library from `dist/`, so build first:

```bash
pnpm build
cd e2e && supabase start && cd .. # requires Docker
pnpm gen:env
pnpm test:e2e
```

See [`e2e/README.md`](e2e/README.md) for details. CI runs this suite in a
separate workflow (`.github/workflows/e2e.yml`).

## Submitting Changes

### Commit Messages
Expand Down
51 changes: 51 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# E2E tests

End-to-end coverage for `@supabase/server`: real GoTrue-issued JWTs, real JWKS
validation over HTTP, real Supabase client operations — across all four
adapters (Hono, H3, Elysia, NestJS) plus the core `withSupabase` fetch
wrapper, the programming model Supabase Edge Functions use.

Unlike the unit/integration tests (mocked env, `jwks: null`), this suite:

- imports the library from `dist/`, not `src/`, so packaging regressions fail here
- reads config from `process.env` via `resolveEnv()` — no mocked env objects
- verifies JWTs against the local stack's live JWKS endpoint — including
rejecting a forged token (real `kid`, wrong signing key), so signature
verification itself is exercised, not just structure checks
- covers both context clients: `supabaseAdmin` (app-layer scoping) and the
user-scoped `supabase` client, where the caller's JWT travels to PostgREST
and a Postgres RLS policy scopes the rows
- runs each adapter app on a real HTTP server and asserts over `fetch`

## Running locally

```sh
pnpm build # e2e imports from dist/
cd e2e && supabase start # local stack (Docker) on ports 5433x
cd .. && pnpm gen:env # writes e2e/.env from `supabase status`
pnpm test:e2e
```

Run a single adapter with `pnpm test:e2e h3`.

## Layout

- `supabase/` — local stack config + `notes` table migration
- `apps/<adapter>/app.ts` — minimal app per adapter, identical route surface:
`GET /health` (public), `GET /me` (user), `GET /me-optional` (user or none),
`GET|POST /notes` (user, admin client scoped by `userClaims.id`),
`GET /my-notes` (user, RLS-scoped client — no WHERE clause),
`GET /all-notes` (user, admin client with no filter — proves the admin
client is not scoped to the caller)
- `apps/core/app.ts` — same surface on the core `withSupabase(config, handler)`
fetch wrapper (no adapter) — what an Edge Function deploys. A real Deno
`supabase functions serve` e2e is tracked as a follow-up issue.
- `scenarios.ts` — the single scenario set run against every adapter
- `setup/global-setup.ts` — checks the stack is up, signs in two test users,
provides their tokens to the tests
- `scripts/gen-env.sh` — writes `.env` (gitignored) from the running stack
- `scripts/get-token.ts` — prints a real user JWT for manual curl testing:
`TOKEN=$(node e2e/scripts/get-token.ts)` (Node 22.18+)

Elysia is Bun-first, but its `app.handle` is a plain fetch handler, so the app
runs behind a `node:http` server (via `srvx`) and CI needs no Bun.
52 changes: 52 additions & 0 deletions e2e/apps/core/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Minimal app on the CORE withSupabase(config, handler) fetch wrapper — no
// framework adapter. This is the exact programming model Supabase Edge
// Functions use (`Deno.serve(withSupabase(...))`); here the same handler runs
// behind node:http. A real Deno `supabase functions serve` e2e is tracked
// separately.
//
// The core wrapper has no router, so routes are dispatched on pathname and
// each auth mode gets its own wrapped handler.
import { withSupabase } from '../../../dist/index.mjs'
import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts'
import { startFetchServer } from '../../setup/serve.ts'

const userHandler = withSupabase({ auth: 'user' }, async (req, ctx) => {
const { pathname } = new URL(req.url)
const { supabase, supabaseAdmin, userClaims } = ctx

if (pathname === '/me') return Response.json({ userClaims })
if (pathname === '/my-notes') {
return Response.json(await listOwnNotes(supabase))
}
if (pathname === '/all-notes') {
return Response.json(await listAllNotes(supabaseAdmin))
}
if (pathname === '/notes' && req.method === 'GET') {
return Response.json(await listNotes(supabaseAdmin, userClaims!.id))
}
if (pathname === '/notes' && req.method === 'POST') {
const { body } = (await req.json()) as { body?: string }
if (!body) {
return Response.json({ error: 'body required' }, { status: 400 })
}
const note = await insertNote(supabaseAdmin, userClaims!.id, body)
return Response.json(note, { status: 201 })
}
return Response.json({ error: 'not found' }, { status: 404 })
})

const optionalHandler = withSupabase(
{ auth: ['user', 'none'] },
async (_req, ctx) => Response.json({ userClaims: ctx.userClaims }),
)

function fetchHandler(req: Request): Response | Promise<Response> {
const { pathname } = new URL(req.url)
if (pathname === '/health') return Response.json({ status: 'ok' })
if (pathname === '/me-optional') return optionalHandler(req)
return userHandler(req)
}

export function start(port: number) {
return startFetchServer(fetchHandler, port)
}
58 changes: 58 additions & 0 deletions e2e/apps/elysia/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Minimal Elysia app exercising @supabase/server from dist/ (not src/), with
// config read from process.env via resolveEnv() — no mocked env anywhere.
//
// Elysia is Bun-first, but `app.handle` is a plain fetch handler, so the app
// runs behind a node:http server (see setup/serve.ts) and CI needs no Bun.
import { Elysia } from 'elysia'

import { withSupabase } from '../../../dist/adapters/elysia/index.mjs'
import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts'
import { startFetchServer } from '../../setup/serve.ts'

const app = new Elysia()
.get('/health', () => ({ status: 'ok' }))
.use(
new Elysia()
.use(withSupabase({ auth: ['user', 'none'] }))
.get('/me-optional', ({ supabaseContext }) => ({
userClaims: supabaseContext.userClaims,
})),
)
.use(
new Elysia()
.use(withSupabase({ auth: 'user' }))
.get('/me', ({ supabaseContext }) => ({
userClaims: supabaseContext.userClaims,
}))
.get('/notes', ({ supabaseContext }) => {
const { supabaseAdmin, userClaims } = supabaseContext
return listNotes(supabaseAdmin, userClaims!.id)
})
// User-scoped client: RLS does the scoping, no WHERE clause.
.get('/my-notes', ({ supabaseContext }) =>
listOwnNotes(supabaseContext.supabase),
)
// Admin client, no filter: sees every user's rows regardless of caller.
.get('/all-notes', ({ supabaseContext }) =>
listAllNotes(supabaseContext.supabaseAdmin),
)
.post('/notes', async ({ supabaseContext, body, set }) => {
const { supabaseAdmin, userClaims } = supabaseContext
const payload = body as { body?: string } | null
if (!payload?.body) {
set.status = 400
return { error: 'body required' }
}
const note = await insertNote(
supabaseAdmin,
userClaims!.id,
payload.body,
)
set.status = 201
return note
}),
)

export function start(port: number) {
return startFetchServer((req) => app.handle(req), port)
}
91 changes: 91 additions & 0 deletions e2e/apps/h3/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Minimal H3 app exercising @supabase/server from dist/ (not src/), with
// config read from process.env via resolveEnv() — no mocked env anywhere.
import { H3, defineHandler } from 'h3'

import { withSupabase } from '../../../dist/adapters/h3/index.mjs'
import type { SupabaseContext } from '../../../dist/index.mjs'
import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts'
import { startFetchServer } from '../../setup/serve.ts'

declare module 'h3' {
interface H3EventContext {
supabaseContext: SupabaseContext
}
}

const requireUser = withSupabase({ auth: 'user' })
const optionalUser = withSupabase({ auth: ['user', 'none'] })

const app = new H3()

app.get('/health', () => ({ status: 'ok' }))

app.get(
'/me',
defineHandler({
middleware: [requireUser],
handler: (event) => ({
userClaims: event.context.supabaseContext.userClaims,
}),
}),
)

app.get(
'/me-optional',
defineHandler({
middleware: [optionalUser],
handler: (event) => ({
userClaims: event.context.supabaseContext.userClaims,
}),
}),
)

app.get(
'/notes',
defineHandler({
middleware: [requireUser],
handler: async (event) => {
const { supabaseAdmin, userClaims } = event.context.supabaseContext
return listNotes(supabaseAdmin, userClaims!.id)
},
}),
)

// User-scoped client: RLS does the scoping, no WHERE clause.
app.get(
'/my-notes',
defineHandler({
middleware: [requireUser],
handler: (event) => listOwnNotes(event.context.supabaseContext.supabase),
}),
)

// Admin client, no filter: sees every user's rows regardless of caller.
app.get(
'/all-notes',
defineHandler({
middleware: [requireUser],
handler: (event) =>
listAllNotes(event.context.supabaseContext.supabaseAdmin),
}),
)

app.post(
'/notes',
defineHandler({
middleware: [requireUser],
handler: async (event) => {
const { supabaseAdmin, userClaims } = event.context.supabaseContext
const { body } = (await event.req.json()) as { body?: string }
if (!body) {
return Response.json({ error: 'body required' }, { status: 400 })
}
const note = await insertNote(supabaseAdmin, userClaims!.id, body)
return Response.json(note, { status: 201 })
},
}),
)

export function start(port: number) {
return startFetchServer(app.fetch, port)
}
52 changes: 52 additions & 0 deletions e2e/apps/hono/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Minimal Hono app exercising @supabase/server from dist/ (not src/), with
// config read from process.env via resolveEnv() — no mocked env anywhere.
import { Hono } from 'hono'

import { withSupabase } from '../../../dist/adapters/hono/index.mjs'
import type { SupabaseContext } from '../../../dist/index.mjs'
import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts'
import { startFetchServer } from '../../setup/serve.ts'

type Env = { Variables: { supabaseContext: SupabaseContext } }

const app = new Hono<Env>()

app.get('/health', (c) => c.json({ status: 'ok' }))

app.use('/me', withSupabase({ auth: 'user' }))
app.use('/me-optional', withSupabase({ auth: ['user', 'none'] }))
app.use('/notes', withSupabase({ auth: 'user' }))
app.use('/my-notes', withSupabase({ auth: 'user' }))
app.use('/all-notes', withSupabase({ auth: 'user' }))

app.get('/me', (c) => c.json({ userClaims: c.var.supabaseContext.userClaims }))

app.get('/me-optional', (c) =>
c.json({ userClaims: c.var.supabaseContext.userClaims }),
)

app.get('/notes', async (c) => {
const { supabaseAdmin, userClaims } = c.var.supabaseContext
return c.json(await listNotes(supabaseAdmin, userClaims!.id))
})

// User-scoped client: RLS does the scoping, no WHERE clause.
app.get('/my-notes', async (c) =>
c.json(await listOwnNotes(c.var.supabaseContext.supabase)),
)

// Admin client, no filter: sees every user's rows regardless of caller.
app.get('/all-notes', async (c) =>
c.json(await listAllNotes(c.var.supabaseContext.supabaseAdmin)),
)

app.post('/notes', async (c) => {
const { supabaseAdmin, userClaims } = c.var.supabaseContext
const { body } = await c.req.json<{ body?: string }>()
if (!body) return c.json({ error: 'body required' }, 400)
return c.json(await insertNote(supabaseAdmin, userClaims!.id, body), 201)
})

export function start(port: number) {
return startFetchServer(app.fetch, port)
}
Loading
Loading