mirror of
https://github.com/supabase/server.git
synced 2026-09-14 15:28:52 +08:00
test(e2e): cover edge functions on the real Deno edge runtime (#111)
* test(e2e): cover edge functions on the real Deno edge runtime * fix(e2e): work around CLI import-scanner crash on vendored JSDoc examples
This commit is contained in:
committed by
GitHub
parent
2b5d4fd82e
commit
c8fd9de39e
@@ -37,6 +37,10 @@ jobs:
|
|||||||
- name: Typecheck E2E suite
|
- name: Typecheck E2E suite
|
||||||
run: pnpm typecheck:e2e
|
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
|
- name: Start local Supabase stack
|
||||||
run: supabase start
|
run: supabase start
|
||||||
working-directory: e2e
|
working-directory: e2e
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ api-docs
|
|||||||
!.env.example
|
!.env.example
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
|
e2e/supabase/functions/_vendor
|
||||||
|
|||||||
+14
-3
@@ -3,7 +3,8 @@
|
|||||||
End-to-end coverage for `@supabase/server`: real GoTrue-issued JWTs, real JWKS
|
End-to-end coverage for `@supabase/server`: real GoTrue-issued JWTs, real JWKS
|
||||||
validation over HTTP, real Supabase client operations — across all four
|
validation over HTTP, real Supabase client operations — across all four
|
||||||
adapters (Hono, H3, Elysia, NestJS) plus the core `withSupabase` fetch
|
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:
|
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
|
```sh
|
||||||
pnpm build # e2e imports from dist/
|
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 e2e && supabase start # local stack (Docker) on ports 5433x
|
||||||
cd .. && pnpm gen:env # writes e2e/.env from `supabase status`
|
cd .. && pnpm gen:env # writes e2e/.env from `supabase status`
|
||||||
pnpm test:e2e
|
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
|
`GET /all-notes` (user, admin client with no filter — proves the admin
|
||||||
client is not scoped to the caller)
|
client is not scoped to the caller)
|
||||||
- `apps/core/app.ts` — same surface on the core `withSupabase(config, handler)`
|
- `apps/core/app.ts` — same surface on the core `withSupabase(config, handler)`
|
||||||
fetch wrapper (no adapter) — what an Edge Function deploys. A real Deno
|
fetch wrapper (no adapter) — what an Edge Function deploys, running on Node.
|
||||||
`supabase functions serve` e2e is tracked as a follow-up issue.
|
- `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
|
- `scenarios.ts` — the single scenario set run against every adapter
|
||||||
- `setup/global-setup.ts` — checks the stack is up, signs in two test users,
|
- `setup/global-setup.ts` — checks the stack is up, signs in two test users,
|
||||||
provides their tokens to the tests
|
provides their tokens to the tests
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Minimal app on the CORE withSupabase(config, handler) fetch wrapper — no
|
// Minimal app on the CORE withSupabase(config, handler) fetch wrapper — no
|
||||||
// framework adapter. This is the exact programming model Supabase Edge
|
// framework adapter. This is the exact programming model Supabase Edge
|
||||||
// Functions use (`Deno.serve(withSupabase(...))`); here the same handler runs
|
// Functions use (`Deno.serve(withSupabase(...))`); here the same handler runs
|
||||||
// behind node:http. A real Deno `supabase functions serve` e2e is tracked
|
// behind node:http. The same surface runs on the real Deno edge runtime in
|
||||||
// separately.
|
// e2e/supabase/functions/server-e2e/ — keep the two in sync.
|
||||||
//
|
//
|
||||||
// The core wrapper has no router, so routes are dispatched on pathname and
|
// The core wrapper has no router, so routes are dispatched on pathname and
|
||||||
// each auth mode gets its own wrapped handler.
|
// each auth mode gets its own wrapped handler.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -34,8 +34,15 @@ enabled = false
|
|||||||
[analytics]
|
[analytics]
|
||||||
enabled = false
|
enabled = false
|
||||||
|
|
||||||
|
# Enabled for the server-e2e function (SDK-1280). Everything else above
|
||||||
|
# stays disabled to keep `supabase start` fast in CI.
|
||||||
[edge_runtime]
|
[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]
|
[auth]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
+2
-1
@@ -12,5 +12,6 @@
|
|||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["."]
|
"include": ["."],
|
||||||
|
"exclude": ["supabase/functions"]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -158,7 +158,8 @@
|
|||||||
"test:e2e": "vitest run --project e2e",
|
"test:e2e": "vitest run --project e2e",
|
||||||
"test:watch": "vitest --project unit --project nestjs",
|
"test:watch": "vitest --project unit --project nestjs",
|
||||||
"typecheck": "tsc --noEmit && tsc --noEmit -p src/adapters/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": {
|
"simple-git-hooks": {
|
||||||
"pre-commit": "pnpm pretty-quick --staged",
|
"pre-commit": "pnpm pretty-quick --staged",
|
||||||
|
|||||||
Reference in New Issue
Block a user