From cfc591ac5c1ef0bc9deac72a958989c0b0c91dea Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 8 Jul 2026 11:50:36 +0300 Subject: [PATCH 1/5] 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. --- .github/workflows/e2e.yml | 51 ++++++++++ CONTRIBUTING.md | 24 +++++ e2e/README.md | 39 ++++++++ e2e/apps/elysia/app.ts | 50 ++++++++++ e2e/apps/h3/app.ts | 72 ++++++++++++++ e2e/apps/hono/app.ts | 40 ++++++++ e2e/apps/nestjs/app.ts | 61 ++++++++++++ e2e/apps/notes.ts | 39 ++++++++ e2e/elysia.e2e.ts | 16 ++++ e2e/h3.e2e.ts | 16 ++++ e2e/hono.e2e.ts | 16 ++++ e2e/nestjs.e2e.ts | 16 ++++ e2e/scenarios.ts | 96 +++++++++++++++++++ e2e/scripts/gen-env.sh | 21 ++++ e2e/scripts/get-token.ts | 18 ++++ e2e/setup/global-setup.ts | 38 ++++++++ e2e/setup/load-env.ts | 30 ++++++ e2e/setup/serve.ts | 20 ++++ e2e/setup/token.ts | 59 ++++++++++++ e2e/setup/vitest-setup.ts | 6 ++ e2e/supabase/.gitignore | 3 + e2e/supabase/config.toml | 48 ++++++++++ .../migrations/20260101000000_notes.sql | 11 +++ e2e/tsconfig.json | 16 ++++ package.json | 21 ++-- pnpm-lock.yaml | 75 +++++++++------ vitest.config.ts | 22 +++++ 27 files changed, 886 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/README.md create mode 100644 e2e/apps/elysia/app.ts create mode 100644 e2e/apps/h3/app.ts create mode 100644 e2e/apps/hono/app.ts create mode 100644 e2e/apps/nestjs/app.ts create mode 100644 e2e/apps/notes.ts create mode 100644 e2e/elysia.e2e.ts create mode 100644 e2e/h3.e2e.ts create mode 100644 e2e/hono.e2e.ts create mode 100644 e2e/nestjs.e2e.ts create mode 100644 e2e/scenarios.ts create mode 100755 e2e/scripts/gen-env.sh create mode 100644 e2e/scripts/get-token.ts create mode 100644 e2e/setup/global-setup.ts create mode 100644 e2e/setup/load-env.ts create mode 100644 e2e/setup/serve.ts create mode 100644 e2e/setup/token.ts create mode 100644 e2e/setup/vitest-setup.ts create mode 100644 e2e/supabase/.gitignore create mode 100644 e2e/supabase/config.toml create mode 100644 e2e/supabase/migrations/20260101000000_notes.sql create mode 100644 e2e/tsconfig.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..72504a5 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0ed005..fdf4be7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..b5deb4f --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,39 @@ +# 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). + +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 +- 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//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`) +- `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. diff --git a/e2e/apps/elysia/app.ts b/e2e/apps/elysia/app.ts new file mode 100644 index 0000000..de36b4b --- /dev/null +++ b/e2e/apps/elysia/app.ts @@ -0,0 +1,50 @@ +// 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, listNotes } 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) + }) + .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) +} diff --git a/e2e/apps/h3/app.ts b/e2e/apps/h3/app.ts new file mode 100644 index 0000000..ddcd885 --- /dev/null +++ b/e2e/apps/h3/app.ts @@ -0,0 +1,72 @@ +// 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, listNotes } 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) + }, + }), +) + +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) +} diff --git a/e2e/apps/hono/app.ts b/e2e/apps/hono/app.ts new file mode 100644 index 0000000..e4b013e --- /dev/null +++ b/e2e/apps/hono/app.ts @@ -0,0 +1,40 @@ +// 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, listNotes } from '../notes.ts' +import { startFetchServer } from '../../setup/serve.ts' + +type Env = { Variables: { supabaseContext: SupabaseContext } } + +const app = new Hono() + +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.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)) +}) + +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) +} diff --git a/e2e/apps/nestjs/app.ts b/e2e/apps/nestjs/app.ts new file mode 100644 index 0000000..a7671a4 --- /dev/null +++ b/e2e/apps/nestjs/app.ts @@ -0,0 +1,61 @@ +// Minimal NestJS app (Express platform) exercising @supabase/server from +// dist/ (not src/), with config read from process.env via resolveEnv(). +// Unlike the supertest-driven integration tests, this listens on a real port. +import 'reflect-metadata' + +import { Body, Controller, Get, Module, Post, UseGuards } from '@nestjs/common' +import { NestFactory } from '@nestjs/core' + +import { + SupabaseCtx, + withSupabase, +} from '../../../dist/adapters/nestjs/index.mjs' +import type { SupabaseContext } from '../../../dist/index.mjs' +import { insertNote, listNotes } from '../notes.ts' + +const UserGuard = withSupabase({ auth: 'user' }) +const OptionalUserGuard = withSupabase({ auth: ['user', 'none'] }) + +@Controller() +class AppController { + @Get('health') + health() { + return { status: 'ok' } + } + + @Get('me') + @UseGuards(UserGuard) + me(@SupabaseCtx('userClaims') userClaims: SupabaseContext['userClaims']) { + return { userClaims } + } + + @Get('me-optional') + @UseGuards(OptionalUserGuard) + meOptional( + @SupabaseCtx('userClaims') userClaims: SupabaseContext['userClaims'], + ) { + return { userClaims: userClaims ?? null } + } + + @Get('notes') + @UseGuards(UserGuard) + list(@SupabaseCtx() ctx: SupabaseContext) { + return listNotes(ctx.supabaseAdmin, ctx.userClaims!.id) + } + + // Nest's default status for POST is already 201. + @Post('notes') + @UseGuards(UserGuard) + create(@Body() dto: { body?: string }, @SupabaseCtx() ctx: SupabaseContext) { + return insertNote(ctx.supabaseAdmin, ctx.userClaims!.id, dto.body ?? '') + } +} + +@Module({ controllers: [AppController] }) +class AppModule {} + +export async function start(port: number): Promise<() => Promise> { + const app = await NestFactory.create(AppModule, { logger: false }) + await app.listen(port) + return () => app.close() +} diff --git a/e2e/apps/notes.ts b/e2e/apps/notes.ts new file mode 100644 index 0000000..ed9eea6 --- /dev/null +++ b/e2e/apps/notes.ts @@ -0,0 +1,39 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +/** Row shape of the e2e `notes` table (see e2e/supabase/migrations). */ +export interface NoteRow { + id: string + user_id: string + body: string +} + +const COLUMNS = 'id, user_id, body' + +/** Inserts a note via the admin client, scoped to the calling user's id. */ +export async function insertNote( + supabaseAdmin: SupabaseClient, + userId: string, + body: string, +): Promise { + const { data, error } = await supabaseAdmin + .from('notes') + .insert({ user_id: userId, body }) + .select(COLUMNS) + .single() + if (error) throw new Error(`insert note failed: ${error.message}`) + return data as NoteRow +} + +/** Lists only the calling user's notes. */ +export async function listNotes( + supabaseAdmin: SupabaseClient, + userId: string, +): Promise { + const { data, error } = await supabaseAdmin + .from('notes') + .select(COLUMNS) + .eq('user_id', userId) + .order('created_at', { ascending: true }) + if (error) throw new Error(`list notes failed: ${error.message}`) + return data as unknown as NoteRow[] +} diff --git a/e2e/elysia.e2e.ts b/e2e/elysia.e2e.ts new file mode 100644 index 0000000..6fece35 --- /dev/null +++ b/e2e/elysia.e2e.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll } from 'vitest' + +import { start } from './apps/elysia/app.ts' +import { runAdapterScenarios } from './scenarios.ts' + +const PORT = 8793 + +let close: () => Promise + +beforeAll(async () => { + close = await start(PORT) +}) + +afterAll(() => close()) + +runAdapterScenarios('elysia', `http://localhost:${PORT}`) diff --git a/e2e/h3.e2e.ts b/e2e/h3.e2e.ts new file mode 100644 index 0000000..b8e6b10 --- /dev/null +++ b/e2e/h3.e2e.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll } from 'vitest' + +import { start } from './apps/h3/app.ts' +import { runAdapterScenarios } from './scenarios.ts' + +const PORT = 8792 + +let close: () => Promise + +beforeAll(async () => { + close = await start(PORT) +}) + +afterAll(() => close()) + +runAdapterScenarios('h3', `http://localhost:${PORT}`) diff --git a/e2e/hono.e2e.ts b/e2e/hono.e2e.ts new file mode 100644 index 0000000..9785c83 --- /dev/null +++ b/e2e/hono.e2e.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll } from 'vitest' + +import { start } from './apps/hono/app.ts' +import { runAdapterScenarios } from './scenarios.ts' + +const PORT = 8791 + +let close: () => Promise + +beforeAll(async () => { + close = await start(PORT) +}) + +afterAll(() => close()) + +runAdapterScenarios('hono', `http://localhost:${PORT}`) diff --git a/e2e/nestjs.e2e.ts b/e2e/nestjs.e2e.ts new file mode 100644 index 0000000..40b093c --- /dev/null +++ b/e2e/nestjs.e2e.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll } from 'vitest' + +import { start } from './apps/nestjs/app.ts' +import { runAdapterScenarios } from './scenarios.ts' + +const PORT = 8794 + +let close: () => Promise + +beforeAll(async () => { + close = await start(PORT) +}) + +afterAll(() => close()) + +runAdapterScenarios('nestjs', `http://localhost:${PORT}`) diff --git a/e2e/scenarios.ts b/e2e/scenarios.ts new file mode 100644 index 0000000..ddb897f --- /dev/null +++ b/e2e/scenarios.ts @@ -0,0 +1,96 @@ +// The one scenario set that runs against all four adapters. Every request +// here travels over real HTTP to a live server; every JWT is a real +// GoTrue-issued token verified against the local stack's live JWKS endpoint. +import { describe, expect, inject, it } from 'vitest' + +import type { NoteRow } from './apps/notes.ts' +import type { TestUser } from './setup/token.ts' + +interface ClaimsResponse { + userClaims: { id: string; email?: string; role?: string } | null +} + +function bearer(user: TestUser): Record { + return { Authorization: `Bearer ${user.token}` } +} + +export function runAdapterScenarios(adapter: string, baseUrl: string): void { + const { user1, user2 } = inject('e2eUsers') + + describe(`${adapter}: auth`, () => { + it('GET /health is public', async () => { + const res = await fetch(`${baseUrl}/health`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ status: 'ok' }) + }) + + it('GET /me without a token → 401', async () => { + const res = await fetch(`${baseUrl}/me`) + expect(res.status).toBe(401) + }) + + it('GET /me with a garbage token → 401', async () => { + const res = await fetch(`${baseUrl}/me`, { + headers: { Authorization: 'Bearer not-a-real-jwt' }, + }) + expect(res.status).toBe(401) + }) + + it('GET /me with a valid token → 200 with the caller identity', async () => { + const res = await fetch(`${baseUrl}/me`, { headers: bearer(user1) }) + expect(res.status).toBe(200) + const { userClaims } = (await res.json()) as ClaimsResponse + expect(userClaims?.id).toBe(user1.id) + expect(userClaims?.email).toBe(user1.email) + expect(userClaims?.role).toBe('authenticated') + }) + + it('GET /me-optional without a token → 200 with null claims', async () => { + const res = await fetch(`${baseUrl}/me-optional`) + expect(res.status).toBe(200) + const { userClaims } = (await res.json()) as ClaimsResponse + expect(userClaims).toBeNull() + }) + + it('GET /me-optional with a valid token → 200 with claims', async () => { + const res = await fetch(`${baseUrl}/me-optional`, { + headers: bearer(user1), + }) + expect(res.status).toBe(200) + const { userClaims } = (await res.json()) as ClaimsResponse + expect(userClaims?.id).toBe(user1.id) + }) + }) + + describe(`${adapter}: data access`, () => { + // Unique per adapter and run — all adapters share one notes table. + const noteBody = `e2e note from ${adapter} ${crypto.randomUUID()}` + let created: NoteRow + + it('POST /notes inserts a row scoped to the caller', async () => { + const res = await fetch(`${baseUrl}/notes`, { + method: 'POST', + headers: { ...bearer(user1), 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: noteBody }), + }) + expect(res.status).toBe(201) + created = (await res.json()) as NoteRow + expect(created.user_id).toBe(user1.id) + expect(created.body).toBe(noteBody) + }) + + it('GET /notes returns the created row', async () => { + const res = await fetch(`${baseUrl}/notes`, { headers: bearer(user1) }) + expect(res.status).toBe(200) + const rows = (await res.json()) as NoteRow[] + expect(rows.some((row) => row.id === created.id)).toBe(true) + expect(rows.every((row) => row.user_id === user1.id)).toBe(true) + }) + + it('GET /notes as a different user cannot see them', async () => { + const res = await fetch(`${baseUrl}/notes`, { headers: bearer(user2) }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + }) +} diff --git a/e2e/scripts/gen-env.sh b/e2e/scripts/gen-env.sh new file mode 100755 index 0000000..65b95e4 --- /dev/null +++ b/e2e/scripts/gen-env.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Writes e2e/.env from the running local stack (`supabase start` in e2e/). +# These are the standard variable names @supabase/server reads via +# resolveEnv() — exactly what you'd set on Vercel / Cloudflare / Railway. +set -euo pipefail + +e2e_dir="$(cd "$(dirname "$0")/.." && pwd)" +cd "$e2e_dir" + +eval "$(supabase status -o env | sed 's/^/export SB_/')" + +cat > .env < Response | Promise, + port: number, +): Promise<() => Promise> { + const server = serve({ fetch: fetchHandler, port }) + await server.ready() + return async () => { + await server.close() + } +} diff --git a/e2e/setup/token.ts b/e2e/setup/token.ts new file mode 100644 index 0000000..0dec27a --- /dev/null +++ b/e2e/setup/token.ts @@ -0,0 +1,59 @@ +import { createClient } from '@supabase/supabase-js' + +/** A signed-in test user with a real GoTrue-issued access token. */ +export interface TestUser { + id: string + email: string + token: string +} + +/** + * Signs in a test user against the local auth API and returns a real JWT. + * Creates the user on first run (email confirmations are disabled in + * e2e/supabase/config.toml, so signUp yields a session immediately). + */ +export async function signInTestUser( + email: string, + password: string, +): Promise { + const url = process.env.SUPABASE_URL + const anonKey = + process.env.SUPABASE_ANON_KEY ?? process.env.SUPABASE_PUBLISHABLE_KEY + if (!url || !anonKey) { + throw new Error( + 'SUPABASE_URL / SUPABASE_ANON_KEY are not set — run `pnpm gen:env` first.', + ) + } + + const supabase = createClient(url, anonKey, { + auth: { persistSession: false }, + }) + + let { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }) + if (error) { + const { error: signUpError } = await supabase.auth.signUp({ + email, + password, + }) + if (signUpError) { + throw new Error( + `could not create test user ${email}: ${signUpError.message}`, + ) + } + ;({ data, error } = await supabase.auth.signInWithPassword({ + email, + password, + })) + } + + if (error || !data.session || !data.user) { + throw new Error( + `could not sign in test user ${email}: ${error?.message ?? 'no session returned'}`, + ) + } + + return { id: data.user.id, email, token: data.session.access_token } +} diff --git a/e2e/setup/vitest-setup.ts b/e2e/setup/vitest-setup.ts new file mode 100644 index 0000000..9a4b893 --- /dev/null +++ b/e2e/setup/vitest-setup.ts @@ -0,0 +1,6 @@ +// Runs in every vitest worker before test files are imported, so the apps' +// resolveEnv() calls (which read process.env at request time) see the local +// stack's configuration. +import { loadEnv } from './load-env.ts' + +loadEnv() diff --git a/e2e/supabase/.gitignore b/e2e/supabase/.gitignore new file mode 100644 index 0000000..1bde153 --- /dev/null +++ b/e2e/supabase/.gitignore @@ -0,0 +1,3 @@ +# Supabase CLI working files +.branches +.temp diff --git a/e2e/supabase/config.toml b/e2e/supabase/config.toml new file mode 100644 index 0000000..8bf0120 --- /dev/null +++ b/e2e/supabase/config.toml @@ -0,0 +1,48 @@ +# Local Supabase stack for the E2E suite. Ports are offset from the CLI +# defaults (54321…) so this stack can run alongside other local projects. +project_id = "supabase-server-e2e" + +[api] +enabled = true +port = 54331 +schemas = ["public", "graphql_public"] +extra_search_path = ["public", "extensions"] +max_rows = 1000 + +[db] +port = 54332 +shadow_port = 54330 +major_version = 17 + +[db.seed] +enabled = false + +# Only the API gateway, database, and auth are needed — everything else is +# disabled to keep `supabase start` fast in CI. +[realtime] +enabled = false + +[studio] +enabled = false + +[inbucket] +enabled = false + +[storage] +enabled = false + +[analytics] +enabled = false + +[edge_runtime] +enabled = false + +[auth] +enabled = true +enable_signup = true + +[auth.email] +enable_signup = true +# Test users are created via signUp at test time; confirmations would require +# an email round-trip. +enable_confirmations = false diff --git a/e2e/supabase/migrations/20260101000000_notes.sql b/e2e/supabase/migrations/20260101000000_notes.sql new file mode 100644 index 0000000..1c4ae2d --- /dev/null +++ b/e2e/supabase/migrations/20260101000000_notes.sql @@ -0,0 +1,11 @@ +-- A minimal per-user table for the data-access scenarios. The test apps use +-- the admin client and scope by user_id (authorization in the API layer); +-- RLS is enabled as good practice (the admin client bypasses it). +create table if not exists public.notes ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id), + body text not null check (char_length(body) <= 500), + created_at timestamptz not null default now() +); + +alter table public.notes enable row level security; diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..62b67f1 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "types": ["node"] + }, + "include": ["."] +} diff --git a/package.json b/package.json index a3ccde0..63377ea 100644 --- a/package.json +++ b/package.json @@ -109,12 +109,15 @@ "dev": "tsdown --watch", "docs": "typedoc", "format": "prettier --write .", + "gen:env": "bash e2e/scripts/gen-env.sh", "lint": "eslint src", "lint:fix": "eslint src --fix", "prepare": "simple-git-hooks", - "test": "vitest run", - "test:watch": "vitest", - "typecheck": "tsc --noEmit && tsc --noEmit -p src/adapters/nestjs" + "test": "vitest run --project unit --project nestjs", + "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" }, "simple-git-hooks": { "pre-commit": "pnpm pretty-quick --staged", @@ -123,9 +126,9 @@ "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@supabase/supabase-js": "^2.0.0", + "elysia": "^1.4.0", "h3": "^2.0.0", - "hono": "^4.0.0", - "elysia": "^1.4.0" + "hono": "^4.0.0" }, "peerDependenciesMeta": { "@nestjs/common": { @@ -142,6 +145,7 @@ } }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.4", "@commitlint/cli": "^20.4.2", "@commitlint/config-conventional": "^20.4.2", "@nestjs/common": "^11.1.19", @@ -151,9 +155,10 @@ "@nestjs/testing": "^11.1.19", "@supabase/supabase-js": "^2.105.4", "@swc/core": "^1.15.33", + "@types/node": "^26.0.1", "@types/supertest": "^7.2.0", - "eslint": "^10.0.2", "elysia": "^1.4.0", + "eslint": "^10.0.2", "h3": "2.0.1-rc.20", "hono": "^4.12.5", "prettier": "3.8.1", @@ -161,14 +166,14 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "simple-git-hooks": "^2.13.1", + "srvx": "^0.11.18", "supertest": "^7.2.2", "tsdown": "^0.20.3", "typedoc": "^0.28.19", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", "unplugin-swc": "^1.5.9", - "vitest": "^4.1.0", - "@arethetypeswrong/cli": "^0.18.4" + "vitest": "^4.1.0" }, "dependencies": { "jose": "^6.2.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 445d55f..01bbc68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,7 +20,7 @@ importers: version: 0.18.4 '@commitlint/cli': specifier: ^20.4.2 - version: 20.4.2(@types/node@25.3.0)(typescript@5.9.3) + version: 20.4.2(@types/node@26.0.1)(typescript@5.9.3) '@commitlint/config-conventional': specifier: ^20.4.2 version: 20.4.2 @@ -45,6 +45,9 @@ importers: '@swc/core': specifier: ^1.15.33 version: 1.15.33 + '@types/node': + specifier: ^26.0.1 + version: 26.0.1 '@types/supertest': specifier: ^7.2.0 version: 7.2.0 @@ -75,6 +78,9 @@ importers: simple-git-hooks: specifier: ^2.13.1 version: 2.13.1 + srvx: + specifier: ^0.11.18 + version: 0.11.18 supertest: specifier: ^7.2.2 version: 7.2.2 @@ -95,7 +101,7 @@ importers: version: 1.5.9(@swc/core@1.15.33)(rollup@4.62.2) vitest: specifier: ^4.1.0 - version: 4.1.8(@types/node@25.3.0)(vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.8(@types/node@26.0.1)(vite@7.3.2(@types/node@26.0.1)(jiti@2.6.1)(yaml@2.8.3)) packages: @@ -993,6 +999,9 @@ packages: '@types/node@25.3.0': resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/superagent@8.1.9': resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} @@ -2082,10 +2091,6 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} - engines: {node: '>=12'} - pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} @@ -2327,6 +2332,11 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + srvx@0.11.18: + resolution: {integrity: sha512-7/EW5sPdC1bU7iq1tgTvCZqUQDkJdsqIVzYqBv7SuBfQQ10oWkKj4KYNOw0H4Ig26bXuUYDA7XTKxB+/HC5SRw==} + engines: {node: '>=20.16.0'} + hasBin: true + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2511,6 +2521,9 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -2748,11 +2761,11 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@commitlint/cli@20.4.2(@types/node@25.3.0)(typescript@5.9.3)': + '@commitlint/cli@20.4.2(@types/node@26.0.1)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.4.0 '@commitlint/lint': 20.4.2 - '@commitlint/load': 20.4.0(@types/node@25.3.0)(typescript@5.9.3) + '@commitlint/load': 20.4.0(@types/node@26.0.1)(typescript@5.9.3) '@commitlint/read': 20.4.0 '@commitlint/types': 20.4.0 tinyexec: 1.0.2 @@ -2799,14 +2812,14 @@ snapshots: '@commitlint/rules': 20.4.2 '@commitlint/types': 20.4.0 - '@commitlint/load@20.4.0(@types/node@25.3.0)(typescript@5.9.3)': + '@commitlint/load@20.4.0(@types/node@26.0.1)(typescript@5.9.3)': dependencies: '@commitlint/config-validator': 20.4.0 '@commitlint/execute-rule': 20.0.0 '@commitlint/resolve-extends': 20.4.0 '@commitlint/types': 20.4.0 cosmiconfig: 9.0.0(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.2.0(@types/node@25.3.0)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.2.0(@types/node@26.0.1)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) is-plain-obj: 4.1.0 lodash.mergewith: 4.6.2 picocolors: 1.1.1 @@ -3436,6 +3449,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/node@26.0.1': + dependencies: + undici-types: 8.3.0 + '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 @@ -3550,13 +3567,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.3))': + '@vitest/mocker@4.1.8(vite@7.3.2(@types/node@26.0.1)(jiti@2.6.1)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@26.0.1)(jiti@2.6.1)(yaml@2.8.3) '@vitest/pretty-format@4.1.8': dependencies: @@ -3804,9 +3821,9 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig-typescript-loader@6.2.0(@types/node@25.3.0)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.2.0(@types/node@26.0.1)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 25.3.0 + '@types/node': 26.0.1 cosmiconfig: 9.0.0(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 @@ -4107,10 +4124,6 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.5): - optionalDependencies: - picomatch: 4.0.5 - fflate@0.8.3: {} file-entry-cache@8.0.0: @@ -4537,8 +4550,6 @@ snapshots: picomatch@4.0.4: {} - picomatch@4.0.5: {} - pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 @@ -4821,6 +4832,8 @@ snapshots: srvx@0.11.15: {} + srvx@0.11.18: {} + stackback@0.0.2: {} statuses@2.0.2: {} @@ -4903,8 +4916,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyrainbow@3.1.0: {} @@ -5012,6 +5025,8 @@ snapshots: undici-types@7.18.2: {} + undici-types@8.3.0: {} + unicode-emoji-modifier-base@1.0.0: {} unpipe@1.0.0: {} @@ -5049,24 +5064,24 @@ snapshots: vary@1.1.2: {} - vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.3): + vite@7.3.2(@types/node@26.0.1)(jiti@2.6.1)(yaml@2.8.3): dependencies: esbuild: 0.28.1 - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 postcss: 8.5.16 rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 26.0.1 fsevents: 2.3.3 jiti: 2.6.1 yaml: 2.8.3 - vitest@4.1.8(@types/node@25.3.0)(vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.3)): + vitest@4.1.8(@types/node@26.0.1)(vite@7.3.2(@types/node@26.0.1)(jiti@2.6.1)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@7.3.2(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.3)) + '@vitest/mocker': 4.1.8(vite@7.3.2(@types/node@26.0.1)(jiti@2.6.1)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -5083,10 +5098,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@26.0.1)(jiti@2.6.1)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 26.0.1 transitivePeerDependencies: - msw diff --git a/vitest.config.ts b/vitest.config.ts index 1616866..abfe325 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,28 @@ export default defineConfig({ include: ['src/adapters/nestjs/**/*.test.ts'], }, }, + { + // E2E suite — requires `pnpm build` and a running local Supabase + // stack (see e2e/README.md). Excluded from plain `pnpm test`; run + // with `pnpm test:e2e`. The swc plugin is needed for the NestJS + // app's decorators. + plugins: [ + swc.vite({ + jsc: { + parser: { syntax: 'typescript', decorators: true }, + transform: { decoratorMetadata: true, legacyDecorator: true }, + }, + }), + ], + test: { + name: 'e2e', + include: ['e2e/**/*.e2e.ts'], + globalSetup: ['e2e/setup/global-setup.ts'], + setupFiles: ['e2e/setup/vitest-setup.ts'], + testTimeout: 15000, + hookTimeout: 30000, + }, + }, ], }, }) From af657799295ec2872eb2d871a84e84d5b8d40d2d Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 8 Jul 2026 12:15:23 +0300 Subject: [PATCH 2/5] test: grant explicit table privileges in the e2e notes migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/supabase/migrations/20260101000000_notes.sql | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/e2e/supabase/migrations/20260101000000_notes.sql b/e2e/supabase/migrations/20260101000000_notes.sql index 1c4ae2d..b640292 100644 --- a/e2e/supabase/migrations/20260101000000_notes.sql +++ b/e2e/supabase/migrations/20260101000000_notes.sql @@ -1,6 +1,6 @@ -- A minimal per-user table for the data-access scenarios. The test apps use --- the admin client and scope by user_id (authorization in the API layer); --- RLS is enabled as good practice (the admin client bypasses it). +-- the admin client (service_role) and scope by user_id (authorization in the +-- API layer); RLS is enabled as good practice (service_role bypasses it). create table if not exists public.notes ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users (id), @@ -9,3 +9,9 @@ create table if not exists public.notes ( ); alter table public.notes enable row level security; + +-- Newer Supabase stacks make new tables private by default: the API roles +-- (anon / authenticated / service_role) get no DML privileges, so access +-- must be granted explicitly. The e2e apps only touch this table through +-- the admin client (service_role). +grant select, insert on public.notes to service_role; From da9e03b9d129b2f53cdab309ecb9178d04f39594 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 8 Jul 2026 12:36:03 +0300 Subject: [PATCH 3/5] test: align NestJS missing-body handling and cover it in the scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/apps/nestjs/app.ts | 13 +++++++++++-- e2e/scenarios.ts | 9 +++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/e2e/apps/nestjs/app.ts b/e2e/apps/nestjs/app.ts index a7671a4..8849bfa 100644 --- a/e2e/apps/nestjs/app.ts +++ b/e2e/apps/nestjs/app.ts @@ -3,7 +3,15 @@ // Unlike the supertest-driven integration tests, this listens on a real port. import 'reflect-metadata' -import { Body, Controller, Get, Module, Post, UseGuards } from '@nestjs/common' +import { + BadRequestException, + Body, + Controller, + Get, + Module, + Post, + UseGuards, +} from '@nestjs/common' import { NestFactory } from '@nestjs/core' import { @@ -47,7 +55,8 @@ class AppController { @Post('notes') @UseGuards(UserGuard) create(@Body() dto: { body?: string }, @SupabaseCtx() ctx: SupabaseContext) { - return insertNote(ctx.supabaseAdmin, ctx.userClaims!.id, dto.body ?? '') + if (!dto?.body) throw new BadRequestException('body required') + return insertNote(ctx.supabaseAdmin, ctx.userClaims!.id, dto.body) } } diff --git a/e2e/scenarios.ts b/e2e/scenarios.ts index ddb897f..65200e2 100644 --- a/e2e/scenarios.ts +++ b/e2e/scenarios.ts @@ -79,6 +79,15 @@ export function runAdapterScenarios(adapter: string, baseUrl: string): void { expect(created.body).toBe(noteBody) }) + it('POST /notes without a body → 400', async () => { + const res = await fetch(`${baseUrl}/notes`, { + method: 'POST', + headers: { ...bearer(user1), 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + }) + it('GET /notes returns the created row', async () => { const res = await fetch(`${baseUrl}/notes`, { headers: bearer(user1) }) expect(res.status).toBe(200) From 8ae63987a177417da165156ca4a8a5321a15b53a Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Wed, 8 Jul 2026 12:48:22 +0300 Subject: [PATCH 4/5] test: cover forged JWTs, the RLS-scoped client, and optional-auth rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/README.md | 10 ++++- e2e/apps/elysia/app.ts | 6 ++- e2e/apps/h3/app.ts | 11 +++++- e2e/apps/hono/app.ts | 8 +++- e2e/apps/nestjs/app.ts | 9 ++++- e2e/apps/notes.ts | 15 ++++++++ e2e/scenarios.ts | 35 +++++++++++++++++ e2e/setup/forge-token.ts | 38 +++++++++++++++++++ e2e/setup/global-setup.ts | 18 +++++++-- .../migrations/20260101000000_notes.sql | 11 +++++- 10 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 e2e/setup/forge-token.ts diff --git a/e2e/README.md b/e2e/README.md index b5deb4f..b62c46c 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -8,7 +8,12 @@ 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 +- 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 @@ -27,7 +32,8 @@ Run a single adapter with `pnpm test:e2e h3`. - `supabase/` — local stack config + `notes` table migration - `apps//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|POST /notes` (user, admin client scoped by `userClaims.id`), + `GET /my-notes` (user, RLS-scoped client — no WHERE clause) - `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/elysia/app.ts b/e2e/apps/elysia/app.ts index de36b4b..9814eaa 100644 --- a/e2e/apps/elysia/app.ts +++ b/e2e/apps/elysia/app.ts @@ -6,7 +6,7 @@ import { Elysia } from 'elysia' import { withSupabase } from '../../../dist/adapters/elysia/index.mjs' -import { insertNote, listNotes } from '../notes.ts' +import { insertNote, listNotes, listOwnNotes } from '../notes.ts' import { startFetchServer } from '../../setup/serve.ts' const app = new Elysia() @@ -28,6 +28,10 @@ const app = new Elysia() 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), + ) .post('/notes', async ({ supabaseContext, body, set }) => { const { supabaseAdmin, userClaims } = supabaseContext const payload = body as { body?: string } | null diff --git a/e2e/apps/h3/app.ts b/e2e/apps/h3/app.ts index ddcd885..dcb842f 100644 --- a/e2e/apps/h3/app.ts +++ b/e2e/apps/h3/app.ts @@ -4,7 +4,7 @@ import { H3, defineHandler } from 'h3' import { withSupabase } from '../../../dist/adapters/h3/index.mjs' import type { SupabaseContext } from '../../../dist/index.mjs' -import { insertNote, listNotes } from '../notes.ts' +import { insertNote, listNotes, listOwnNotes } from '../notes.ts' import { startFetchServer } from '../../setup/serve.ts' declare module 'h3' { @@ -51,6 +51,15 @@ app.get( }), ) +// User-scoped client: RLS does the scoping, no WHERE clause. +app.get( + '/my-notes', + defineHandler({ + middleware: [requireUser], + handler: (event) => listOwnNotes(event.context.supabaseContext.supabase), + }), +) + app.post( '/notes', defineHandler({ diff --git a/e2e/apps/hono/app.ts b/e2e/apps/hono/app.ts index e4b013e..e2056ab 100644 --- a/e2e/apps/hono/app.ts +++ b/e2e/apps/hono/app.ts @@ -4,7 +4,7 @@ import { Hono } from 'hono' import { withSupabase } from '../../../dist/adapters/hono/index.mjs' import type { SupabaseContext } from '../../../dist/index.mjs' -import { insertNote, listNotes } from '../notes.ts' +import { insertNote, listNotes, listOwnNotes } from '../notes.ts' import { startFetchServer } from '../../setup/serve.ts' type Env = { Variables: { supabaseContext: SupabaseContext } } @@ -16,6 +16,7 @@ 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.get('/me', (c) => c.json({ userClaims: c.var.supabaseContext.userClaims })) @@ -28,6 +29,11 @@ app.get('/notes', async (c) => { 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)), +) + app.post('/notes', async (c) => { const { supabaseAdmin, userClaims } = c.var.supabaseContext const { body } = await c.req.json<{ body?: string }>() diff --git a/e2e/apps/nestjs/app.ts b/e2e/apps/nestjs/app.ts index 8849bfa..8427cf5 100644 --- a/e2e/apps/nestjs/app.ts +++ b/e2e/apps/nestjs/app.ts @@ -19,7 +19,7 @@ import { withSupabase, } from '../../../dist/adapters/nestjs/index.mjs' import type { SupabaseContext } from '../../../dist/index.mjs' -import { insertNote, listNotes } from '../notes.ts' +import { insertNote, listNotes, listOwnNotes } from '../notes.ts' const UserGuard = withSupabase({ auth: 'user' }) const OptionalUserGuard = withSupabase({ auth: ['user', 'none'] }) @@ -51,6 +51,13 @@ class AppController { return listNotes(ctx.supabaseAdmin, ctx.userClaims!.id) } + // User-scoped client: RLS does the scoping, no WHERE clause. + @Get('my-notes') + @UseGuards(UserGuard) + listOwn(@SupabaseCtx() ctx: SupabaseContext) { + return listOwnNotes(ctx.supabase) + } + // Nest's default status for POST is already 201. @Post('notes') @UseGuards(UserGuard) diff --git a/e2e/apps/notes.ts b/e2e/apps/notes.ts index ed9eea6..6b2646c 100644 --- a/e2e/apps/notes.ts +++ b/e2e/apps/notes.ts @@ -24,6 +24,21 @@ export async function insertNote( return data as NoteRow } +/** + * Lists notes through the user-scoped client — no WHERE clause. The caller's + * JWT travels to PostgREST and the RLS policy alone scopes the rows. + */ +export async function listOwnNotes( + supabase: SupabaseClient, +): Promise { + 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 data as unknown as NoteRow[] +} + /** Lists only the calling user's notes. */ export async function listNotes( supabaseAdmin: SupabaseClient, diff --git a/e2e/scenarios.ts b/e2e/scenarios.ts index 65200e2..6931042 100644 --- a/e2e/scenarios.ts +++ b/e2e/scenarios.ts @@ -16,6 +16,7 @@ function bearer(user: TestUser): Record { export function runAdapterScenarios(adapter: string, baseUrl: string): void { const { user1, user2 } = inject('e2eUsers') + const forgedToken = inject('forgedToken') describe(`${adapter}: auth`, () => { it('GET /health is public', async () => { @@ -36,6 +37,15 @@ export function runAdapterScenarios(adapter: string, baseUrl: string): void { expect(res.status).toBe(401) }) + it('GET /me with a well-formed JWT signed by the wrong key → 401', async () => { + // Same alg and kid as the live JWKS, wrong signing key — this must + // fail at signature verification, not at structure checks. + const res = await fetch(`${baseUrl}/me`, { + headers: { Authorization: `Bearer ${forgedToken}` }, + }) + expect(res.status).toBe(401) + }) + it('GET /me with a valid token → 200 with the caller identity', async () => { const res = await fetch(`${baseUrl}/me`, { headers: bearer(user1) }) expect(res.status).toBe(200) @@ -52,6 +62,15 @@ export function runAdapterScenarios(adapter: string, baseUrl: string): void { expect(userClaims).toBeNull() }) + it('GET /me-optional with an invalid token → 401, not anonymous', async () => { + // A present-but-invalid token must be rejected, never silently + // downgraded to the 'none' mode. + const res = await fetch(`${baseUrl}/me-optional`, { + headers: { Authorization: 'Bearer not-a-real-jwt' }, + }) + expect(res.status).toBe(401) + }) + it('GET /me-optional with a valid token → 200 with claims', async () => { const res = await fetch(`${baseUrl}/me-optional`, { headers: bearer(user1), @@ -101,5 +120,21 @@ export function runAdapterScenarios(adapter: string, baseUrl: string): void { expect(res.status).toBe(200) expect(await res.json()).toEqual([]) }) + + it('GET /my-notes scopes rows via RLS through the user client', async () => { + // The route has no WHERE clause — the caller's JWT reaches PostgREST + // through ctx.supabase and the RLS policy alone scopes the rows. + const res = await fetch(`${baseUrl}/my-notes`, { headers: bearer(user1) }) + expect(res.status).toBe(200) + const rows = (await res.json()) as NoteRow[] + expect(rows.some((row) => row.id === created.id)).toBe(true) + expect(rows.every((row) => row.user_id === user1.id)).toBe(true) + }) + + it('GET /my-notes as a different user is empty via RLS', async () => { + const res = await fetch(`${baseUrl}/my-notes`, { headers: bearer(user2) }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) }) } diff --git a/e2e/setup/forge-token.ts b/e2e/setup/forge-token.ts new file mode 100644 index 0000000..435cd36 --- /dev/null +++ b/e2e/setup/forge-token.ts @@ -0,0 +1,38 @@ +import { generateKeyPair, SignJWT } from 'jose' + +/** + * Mints a structurally valid JWT that mimics the local stack's real tokens — + * same `alg` and `kid` as the live JWKS — but signed with a freshly generated + * key the stack has never seen. It must fail signature verification: this is + * the forged-token case that proves JWKS validation checks the signature, + * not just the token's structure. + */ +export async function mintForgedToken(sub: string): Promise { + const jwksUrl = process.env.SUPABASE_JWKS_URL + if (!jwksUrl) { + throw new Error('SUPABASE_JWKS_URL is not set — run `pnpm gen:env` first.') + } + + const jwks = (await (await fetch(jwksUrl)).json()) as { + keys: Array<{ alg?: string; kid?: string; kty?: string }> + } + const key = jwks.keys[0] + if (!key?.kid) { + throw new Error(`JWKS at ${jwksUrl} has no usable key with a kid.`) + } + if (key.kty === 'oct') { + throw new Error( + 'Local stack uses a symmetric signing key — cannot mint a forged ' + + 'asymmetric token. Newer Supabase stacks default to ES256.', + ) + } + const alg = key.alg ?? 'ES256' + + const { privateKey } = await generateKeyPair(alg) + return new SignJWT({ role: 'authenticated', email: 'forged@example.com' }) + .setProtectedHeader({ alg, kid: key.kid }) + .setSubject(sub) + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 757d8e2..5a83197 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -1,11 +1,13 @@ import type { TestProject } from 'vitest/node' +import { mintForgedToken } from './forge-token.ts' import { loadEnv } from './load-env.ts' import { signInTestUser, type TestUser } from './token.ts' declare module 'vitest' { interface ProvidedContext { e2eUsers: { user1: TestUser; user2: TestUser } + forgedToken: string } } @@ -31,8 +33,16 @@ export default async function setup(project: TestProject) { // Two users: user1 drives the happy-path scenarios, user2 exists solely to // prove data isolation (it must never insert notes). - project.provide('e2eUsers', { - user1: await signInTestUser('e2e-user-1@example.com', 'password-user-1'), - user2: await signInTestUser('e2e-user-2@example.com', 'password-user-2'), - }) + const user1 = await signInTestUser( + 'e2e-user-1@example.com', + 'password-user-1', + ) + const user2 = await signInTestUser( + 'e2e-user-2@example.com', + 'password-user-2', + ) + project.provide('e2eUsers', { user1, user2 }) + + // Well-formed JWT with the real kid but the wrong signing key — must 401. + project.provide('forgedToken', await mintForgedToken(user1.id)) } diff --git a/e2e/supabase/migrations/20260101000000_notes.sql b/e2e/supabase/migrations/20260101000000_notes.sql index b640292..81ef6fc 100644 --- a/e2e/supabase/migrations/20260101000000_notes.sql +++ b/e2e/supabase/migrations/20260101000000_notes.sql @@ -12,6 +12,13 @@ alter table public.notes enable row level security; -- Newer Supabase stacks make new tables private by default: the API roles -- (anon / authenticated / service_role) get no DML privileges, so access --- must be granted explicitly. The e2e apps only touch this table through --- the admin client (service_role). +-- must be granted explicitly. grant select, insert on public.notes to service_role; + +-- User-scoped read path for the ctx.supabase (RLS) scenarios: authenticated +-- callers may read only their own rows — enforced by Postgres, not the app. +grant select on public.notes to authenticated; + +create policy "users can read their own notes" + on public.notes for select to authenticated + using ((select auth.uid()) = user_id); From ed5bb26c0203c307feadafc53d4f22a8a5c90a10 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Thu, 9 Jul 2026 10:24:04 +0300 Subject: [PATCH 5/5] test: add core-wrapper app, admin-bypass proof, and sign-in readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/README.md | 10 ++++++-- e2e/apps/core/app.ts | 52 ++++++++++++++++++++++++++++++++++++++++++ e2e/apps/elysia/app.ts | 6 ++++- e2e/apps/h3/app.ts | 12 +++++++++- e2e/apps/hono/app.ts | 8 ++++++- e2e/apps/nestjs/app.ts | 9 +++++++- e2e/apps/notes.ts | 15 ++++++++++++ e2e/core.e2e.ts | 16 +++++++++++++ e2e/scenarios.ts | 12 ++++++++++ e2e/setup/token.ts | 14 ++++-------- 10 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 e2e/apps/core/app.ts create mode 100644 e2e/core.e2e.ts diff --git a/e2e/README.md b/e2e/README.md index b62c46c..2a34486 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -2,7 +2,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). +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: @@ -33,7 +34,12 @@ Run a single adapter with `pnpm test:e2e h3`. - `apps//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 /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 diff --git a/e2e/apps/core/app.ts b/e2e/apps/core/app.ts new file mode 100644 index 0000000..fe623fa --- /dev/null +++ b/e2e/apps/core/app.ts @@ -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 { + 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) +} diff --git a/e2e/apps/elysia/app.ts b/e2e/apps/elysia/app.ts index 9814eaa..53315ca 100644 --- a/e2e/apps/elysia/app.ts +++ b/e2e/apps/elysia/app.ts @@ -6,7 +6,7 @@ import { Elysia } from 'elysia' import { withSupabase } from '../../../dist/adapters/elysia/index.mjs' -import { insertNote, listNotes, listOwnNotes } from '../notes.ts' +import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts' import { startFetchServer } from '../../setup/serve.ts' const app = new Elysia() @@ -32,6 +32,10 @@ const app = new Elysia() .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 diff --git a/e2e/apps/h3/app.ts b/e2e/apps/h3/app.ts index dcb842f..bac7705 100644 --- a/e2e/apps/h3/app.ts +++ b/e2e/apps/h3/app.ts @@ -4,7 +4,7 @@ import { H3, defineHandler } from 'h3' import { withSupabase } from '../../../dist/adapters/h3/index.mjs' import type { SupabaseContext } from '../../../dist/index.mjs' -import { insertNote, listNotes, listOwnNotes } from '../notes.ts' +import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts' import { startFetchServer } from '../../setup/serve.ts' declare module 'h3' { @@ -60,6 +60,16 @@ app.get( }), ) +// 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({ diff --git a/e2e/apps/hono/app.ts b/e2e/apps/hono/app.ts index e2056ab..6cf5d03 100644 --- a/e2e/apps/hono/app.ts +++ b/e2e/apps/hono/app.ts @@ -4,7 +4,7 @@ import { Hono } from 'hono' import { withSupabase } from '../../../dist/adapters/hono/index.mjs' import type { SupabaseContext } from '../../../dist/index.mjs' -import { insertNote, listNotes, listOwnNotes } from '../notes.ts' +import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts' import { startFetchServer } from '../../setup/serve.ts' type Env = { Variables: { supabaseContext: SupabaseContext } } @@ -17,6 +17,7 @@ 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 })) @@ -34,6 +35,11 @@ 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 }>() diff --git a/e2e/apps/nestjs/app.ts b/e2e/apps/nestjs/app.ts index 8427cf5..d84d7b6 100644 --- a/e2e/apps/nestjs/app.ts +++ b/e2e/apps/nestjs/app.ts @@ -19,7 +19,7 @@ import { withSupabase, } from '../../../dist/adapters/nestjs/index.mjs' import type { SupabaseContext } from '../../../dist/index.mjs' -import { insertNote, listNotes, listOwnNotes } from '../notes.ts' +import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts' const UserGuard = withSupabase({ auth: 'user' }) const OptionalUserGuard = withSupabase({ auth: ['user', 'none'] }) @@ -58,6 +58,13 @@ class AppController { return listOwnNotes(ctx.supabase) } + // Admin client, no filter: sees every user's rows regardless of caller. + @Get('all-notes') + @UseGuards(UserGuard) + listAll(@SupabaseCtx() ctx: SupabaseContext) { + return listAllNotes(ctx.supabaseAdmin) + } + // Nest's default status for POST is already 201. @Post('notes') @UseGuards(UserGuard) diff --git a/e2e/apps/notes.ts b/e2e/apps/notes.ts index 6b2646c..66f8f9b 100644 --- a/e2e/apps/notes.ts +++ b/e2e/apps/notes.ts @@ -24,6 +24,21 @@ export async function insertNote( return data as NoteRow } +/** + * Lists ALL notes through the admin client — every user's rows. Proves the + * admin client is not scoped to the caller's identity and bypasses RLS. + */ +export async function listAllNotes( + supabaseAdmin: SupabaseClient, +): Promise { + 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 data as unknown as NoteRow[] +} + /** * Lists notes through the user-scoped client — no WHERE clause. The caller's * JWT travels to PostgREST and the RLS policy alone scopes the rows. diff --git a/e2e/core.e2e.ts b/e2e/core.e2e.ts new file mode 100644 index 0000000..303a3bc --- /dev/null +++ b/e2e/core.e2e.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll } from 'vitest' + +import { start } from './apps/core/app.ts' +import { runAdapterScenarios } from './scenarios.ts' + +const PORT = 8795 + +let close: () => Promise + +beforeAll(async () => { + close = await start(PORT) +}) + +afterAll(() => close()) + +runAdapterScenarios('core', `http://localhost:${PORT}`) diff --git a/e2e/scenarios.ts b/e2e/scenarios.ts index 6931042..38f86f4 100644 --- a/e2e/scenarios.ts +++ b/e2e/scenarios.ts @@ -121,6 +121,18 @@ export function runAdapterScenarios(adapter: string, baseUrl: string): void { expect(await res.json()).toEqual([]) }) + it('GET /all-notes via the admin client sees other users rows', async () => { + // user2's request reads through supabaseAdmin with no filter — user1's + // row must be visible, proving the admin client is not scoped to the + // caller's identity and bypasses RLS. + const res = await fetch(`${baseUrl}/all-notes`, { + headers: bearer(user2), + }) + expect(res.status).toBe(200) + const rows = (await res.json()) as NoteRow[] + expect(rows.some((row) => row.id === created.id)).toBe(true) + }) + it('GET /my-notes scopes rows via RLS through the user client', async () => { // The route has no WHERE clause — the caller's JWT reaches PostgREST // through ctx.supabase and the RLS policy alone scopes the rows. diff --git a/e2e/setup/token.ts b/e2e/setup/token.ts index 0dec27a..bbe3398 100644 --- a/e2e/setup/token.ts +++ b/e2e/setup/token.ts @@ -29,11 +29,9 @@ export async function signInTestUser( auth: { persistSession: false }, }) - let { data, error } = await supabase.auth.signInWithPassword({ - email, - password, - }) - if (error) { + let result = await supabase.auth.signInWithPassword({ email, password }) + if (result.error) { + // First run: the user doesn't exist yet — create it, then sign in again. const { error: signUpError } = await supabase.auth.signUp({ email, password, @@ -43,12 +41,10 @@ export async function signInTestUser( `could not create test user ${email}: ${signUpError.message}`, ) } - ;({ data, error } = await supabase.auth.signInWithPassword({ - email, - password, - })) + result = await supabase.auth.signInWithPassword({ email, password }) } + const { data, error } = result if (error || !data.session || !data.user) { throw new Error( `could not sign in test user ${email}: ${error?.message ?? 'no session returned'}`,