diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 72504a5..169ccca 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/.gitignore b/.gitignore index 245a4e7..ccfb676 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ api-docs !.env.example .DS_Store .idea +e2e/supabase/functions/_vendor diff --git a/e2e/README.md b/e2e/README.md index 2a34486..7e64101 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -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: @@ -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 @@ -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 diff --git a/e2e/apps/core/app.ts b/e2e/apps/core/app.ts index fe623fa..81688ba 100644 --- a/e2e/apps/core/app.ts +++ b/e2e/apps/core/app.ts @@ -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. diff --git a/e2e/edge.e2e.ts b/e2e/edge.e2e.ts new file mode 100644 index 0000000..9b11184 --- /dev/null +++ b/e2e/edge.e2e.ts @@ -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) diff --git a/e2e/scripts/vendor-pack.sh b/e2e/scripts/vendor-pack.sh new file mode 100644 index 0000000..eefd5ea --- /dev/null +++ b/e2e/scripts/vendor-pack.sh @@ -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" diff --git a/e2e/supabase/config.toml b/e2e/supabase/config.toml index 8bf0120..92ad72d 100644 --- a/e2e/supabase/config.toml +++ b/e2e/supabase/config.toml @@ -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 diff --git a/e2e/supabase/functions/server-e2e/deno.json b/e2e/supabase/functions/server-e2e/deno.json new file mode 100644 index 0000000..4b208a8 --- /dev/null +++ b/e2e/supabase/functions/server-e2e/deno.json @@ -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" + } +} diff --git a/e2e/supabase/functions/server-e2e/index.ts b/e2e/supabase/functions/server-e2e/index.ts new file mode 100644 index 0000000..6c9d5f4 --- /dev/null +++ b/e2e/supabase/functions/server-e2e/index.ts @@ -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/ 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) +}) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 62b67f1..3608c24 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -12,5 +12,6 @@ "emitDecoratorMetadata": true, "types": ["node"] }, - "include": ["."] + "include": ["."], + "exclude": ["supabase/functions"] } diff --git a/package.json b/package.json index 4cd6eed..08a208a 100644 --- a/package.json +++ b/package.json @@ -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",