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
4 changes: 4 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ jobs:
- name: Typecheck E2E suite
run: pnpm typecheck:e2e

# The edge function imports the packed library artifact — vendor it
# into supabase/functions before the stack starts (see e2e/README.md).
- run: pnpm vendor:e2e

- name: Start local Supabase stack
run: supabase start
working-directory: e2e
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ api-docs
!.env.example
.DS_Store
.idea
e2e/supabase/functions/_vendor
17 changes: 14 additions & 3 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
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.
wrapper on Node and on the real Deno edge runtime, the programming model
Supabase Edge Functions use.

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

Expand All @@ -21,6 +22,7 @@ Unlike the unit/integration tests (mocked env, `jwks: null`), this suite:

```sh
pnpm build # e2e imports from dist/
pnpm vendor:e2e # packs dist/ for the edge function's import map
cd e2e && supabase start # local stack (Docker) on ports 5433x
cd .. && pnpm gen:env # writes e2e/.env from `supabase status`
pnpm test:e2e
Expand All @@ -38,8 +40,17 @@ Run a single adapter with `pnpm test:e2e h3`.
`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.
fetch wrapper (no adapter) — what an Edge Function deploys, running on Node.
- `supabase/functions/server-e2e/` — the same surface again, but on the real
Deno edge runtime, served by `supabase start` through the Kong gateway and
covered by `edge.e2e.ts`. Imports the library from a vendored `pnpm pack`
artifact (`pnpm vendor:e2e` → `functions/_vendor/`, gitignored) — the exact
bytes `npm publish` ships. `verify_jwt = false` in `supabase/config.toml`
keeps the gateway's JWT pre-check out of the way so the middleware's own
401 behavior is what the scenarios exercise. Needs Supabase CLI ≥ 2.109 —
older local edge runtimes don't inject the new-key env
(`SUPABASE_PUBLISHABLE_KEYS` / `SUPABASE_SECRET_KEYS` / `SUPABASE_JWKS`)
the library reads.
- `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
Expand Down
4 changes: 2 additions & 2 deletions e2e/apps/core/app.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// 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.
// behind node:http. The same surface runs on the real Deno edge runtime in
// e2e/supabase/functions/server-e2e/ — keep the two in sync.
//
// The core wrapper has no router, so routes are dispatched on pathname and
// each auth mode gets its own wrapped handler.
Expand Down
35 changes: 35 additions & 0 deletions e2e/edge.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { beforeAll } from 'vitest'

import { runAdapterScenarios } from './scenarios.ts'

// Served by the local stack's edge runtime (`supabase start` with
// [edge_runtime] enabled in e2e/supabase/config.toml) and reached through
// the Kong gateway — unlike the app suites, there is no local server to
// start. vitest-setup.ts has already loaded e2e/.env by the time this
// module is evaluated.
const baseUrl = `${process.env.SUPABASE_URL}/functions/v1/server-e2e`

beforeAll(async () => {
// The first invocation boots the Deno worker and resolves its npm:
// imports — poll /health so the cold start is spent here, not inside the
// first scenario's 15s timeout.
const deadline = Date.now() + 90_000
for (;;) {
try {
const res = await fetch(`${baseUrl}/health`)
if (res.ok) return
} catch {
// gateway not answering yet
}
if (Date.now() > deadline) {
throw new Error(
`edge function at ${baseUrl} not ready after 90s — is the stack ` +
'running with [edge_runtime] enabled and the vendor step done? ' +
'(pnpm vendor:e2e, then supabase start in e2e/)',
)
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}, 120_000)

runAdapterScenarios('edge', baseUrl)
36 changes: 36 additions & 0 deletions e2e/scripts/vendor-pack.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Vendors the packed library into the edge functions directory. The edge
# runtime container mounts only e2e/supabase/functions/, so the function
# cannot import the repo-root dist/ directly. `pnpm pack` produces the exact
# artifact `npm publish` would ship — the suite keeps testing built output.
# Requires `pnpm build` first.
set -euo pipefail

repo_root="$(cd "$(dirname "$0")/../.." && pwd)"
vendor_dir="$repo_root/e2e/supabase/functions/_vendor"

if [ ! -f "$repo_root/dist/index.mjs" ]; then
echo "dist/index.mjs not found — run \`pnpm build\` first" >&2
exit 1
fi

rm -rf "$vendor_dir"
mkdir -p "$vendor_dir"

tarball="$(pnpm --dir "$repo_root" pack --pack-destination "$vendor_dir" | tail -n1)"
tar -xzf "$tarball" -C "$vendor_dir"
rm "$tarball"

# Workaround for a Supabase CLI regression (JS rewrite, >= 2.110): its
# functions import scanner also picks specifiers out of JSDoc @example
# comments, resolves subpaths by concatenating onto the bare import-map key
# ('@supabase/server' + '/core' -> .../index.mjs/core), and aborts
# `supabase start` on the resulting ENOTDIR. Neutralize subpath specifiers
# on comment lines only (dist code imports use double quotes; JSDoc
# examples use single quotes) — runtime code is untouched. Remove once the
# CLI treats comment text / not-found paths correctly.
find "$vendor_dir/package/dist" -type f \( -name '*.mjs' -o -name '*.cjs' -o -name '*.mts' -o -name '*.cts' \) \
-exec sed -i.bak "/^[[:space:]]*\*/ s|'@supabase/server/[^']*'|'@supabase/server'|g" {} + \
&& find "$vendor_dir/package/dist" -name '*.bak' -delete

echo "Vendored $(basename "$tarball") -> ${vendor_dir#"$repo_root/"}/package"
9 changes: 8 additions & 1 deletion e2e/supabase/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ enabled = false
[analytics]
enabled = false

# Enabled for the server-e2e function (SDK-1280). Everything else above
# stays disabled to keep `supabase start` fast in CI.
[edge_runtime]
enabled = false
enabled = true

# The middleware's own 401 behavior is under test — the gateway's JWT
# pre-check must not answer first.
[functions.server-e2e]
verify_jwt = false

[auth]
enabled = true
Expand Down
9 changes: 9 additions & 0 deletions e2e/supabase/functions/server-e2e/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"imports": {
"@supabase/server": "../_vendor/package/dist/index.mjs",
"@supabase/supabase-js": "npm:@supabase/supabase-js@2",
"@supabase/supabase-js/cors": "npm:@supabase/supabase-js@2/cors",
"@supabase/middleware": "npm:@supabase/middleware@0.3.0",
"jose": "npm:jose@6"
}
}
82 changes: 82 additions & 0 deletions e2e/supabase/functions/server-e2e/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// The e2e edge function: the same route surface as e2e/apps/core/app.ts —
// the same core withSupabase(config, handler) wrapper — but on the real
// Deno edge runtime behind the local gateway. Kept self-contained: the
// runtime mounts only supabase/functions/, so the shared e2e helpers are
// out of reach and the small dispatch + queries are duplicated by design.
// Keep the two files in sync when the route surface changes.
import { withSupabase } from '@supabase/server'

const COLUMNS = 'id, user_id, body'

// The gateway forwards /functions/v1/server-e2e/<path> with the function
// name still in the pathname; some CLI versions strip it. Handle both.
function route(req: Request): string {
const { pathname } = new URL(req.url)
return pathname.replace(/^\/server-e2e/, '') || '/'
}

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

if (pathname === '/me') return Response.json({ userClaims })

if (pathname === '/my-notes') {
// No WHERE clause — the caller's JWT reaches PostgREST through
// ctx.supabase and the RLS policy alone scopes the rows.
const { data, error } = await supabase
.from('notes')
.select(COLUMNS)
.order('created_at', { ascending: true })
if (error) throw new Error(`list own notes failed: ${error.message}`)
return Response.json(data)
}

if (pathname === '/all-notes') {
// Admin client, no filter — proves it is not scoped to the caller.
const { data, error } = await supabaseAdmin
.from('notes')
.select(COLUMNS)
.order('created_at', { ascending: true })
if (error) throw new Error(`list all notes failed: ${error.message}`)
return Response.json(data)
}

if (pathname === '/notes' && req.method === 'GET') {
const { data, error } = await supabaseAdmin
.from('notes')
.select(COLUMNS)
.eq('user_id', userClaims!.id)
.order('created_at', { ascending: true })
if (error) throw new Error(`list notes failed: ${error.message}`)
return Response.json(data)
}

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 { data, error } = await supabaseAdmin
.from('notes')
.insert({ user_id: userClaims!.id, body })
.select(COLUMNS)
.single()
if (error) throw new Error(`insert note failed: ${error.message}`)
return Response.json(data, { status: 201 })
}

return Response.json({ error: 'not found' }, { status: 404 })
})

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

Deno.serve((req) => {
const pathname = route(req)
if (pathname === '/health') return Response.json({ status: 'ok' })
if (pathname === '/me-optional') return optionalHandler(req)
return userHandler(req)
})
3 changes: 2 additions & 1 deletion e2e/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@
"emitDecoratorMetadata": true,
"types": ["node"]
},
"include": ["."]
"include": ["."],
"exclude": ["supabase/functions"]
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@
"test:e2e": "vitest run --project e2e",
"test:watch": "vitest --project unit --project nestjs",
"typecheck": "tsc --noEmit && tsc --noEmit -p src/adapters/nestjs",
"typecheck:e2e": "tsc --noEmit -p e2e"
"typecheck:e2e": "tsc --noEmit -p e2e",
"vendor:e2e": "bash e2e/scripts/vendor-pack.sh"
},
"simple-git-hooks": {
"pre-commit": "pnpm pretty-quick --staged",
Expand Down
Loading