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..2a34486 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,51 @@ +# E2E tests + +End-to-end coverage for `@supabase/server`: real GoTrue-issued JWTs, real JWKS +validation over HTTP, real Supabase client operations — across all four +adapters (Hono, H3, Elysia, NestJS) plus the core `withSupabase` fetch +wrapper, the programming model Supabase Edge Functions use. + +Unlike the unit/integration tests (mocked env, `jwks: null`), this suite: + +- imports the library from `dist/`, not `src/`, so packaging regressions fail here +- reads config from `process.env` via `resolveEnv()` — no mocked env objects +- verifies JWTs against the local stack's live JWKS endpoint — including + rejecting a forged token (real `kid`, wrong signing key), so signature + verification itself is exercised, not just structure checks +- covers both context clients: `supabaseAdmin` (app-layer scoping) and the + user-scoped `supabase` client, where the caller's JWT travels to PostgREST + and a Postgres RLS policy scopes the rows +- runs each adapter app on a real HTTP server and asserts over `fetch` + +## Running locally + +```sh +pnpm build # e2e imports from dist/ +cd e2e && supabase start # local stack (Docker) on ports 5433x +cd .. && pnpm gen:env # writes e2e/.env from `supabase status` +pnpm test:e2e +``` + +Run a single adapter with `pnpm test:e2e h3`. + +## Layout + +- `supabase/` — local stack config + `notes` table migration +- `apps//app.ts` — minimal app per adapter, identical route surface: + `GET /health` (public), `GET /me` (user), `GET /me-optional` (user or none), + `GET|POST /notes` (user, admin client scoped by `userClaims.id`), + `GET /my-notes` (user, RLS-scoped client — no WHERE clause), + `GET /all-notes` (user, admin client with no filter — proves the admin + client is not scoped to the caller) +- `apps/core/app.ts` — same surface on the core `withSupabase(config, handler)` + fetch wrapper (no adapter) — what an Edge Function deploys. A real Deno + `supabase functions serve` e2e is tracked as a follow-up issue. +- `scenarios.ts` — the single scenario set run against every adapter +- `setup/global-setup.ts` — checks the stack is up, signs in two test users, + provides their tokens to the tests +- `scripts/gen-env.sh` — writes `.env` (gitignored) from the running stack +- `scripts/get-token.ts` — prints a real user JWT for manual curl testing: + `TOKEN=$(node e2e/scripts/get-token.ts)` (Node 22.18+) + +Elysia is Bun-first, but its `app.handle` is a plain fetch handler, so the app +runs behind a `node:http` server (via `srvx`) and CI needs no Bun. 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 new file mode 100644 index 0000000..53315ca --- /dev/null +++ b/e2e/apps/elysia/app.ts @@ -0,0 +1,58 @@ +// Minimal Elysia app exercising @supabase/server from dist/ (not src/), with +// config read from process.env via resolveEnv() — no mocked env anywhere. +// +// Elysia is Bun-first, but `app.handle` is a plain fetch handler, so the app +// runs behind a node:http server (see setup/serve.ts) and CI needs no Bun. +import { Elysia } from 'elysia' + +import { withSupabase } from '../../../dist/adapters/elysia/index.mjs' +import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts' +import { startFetchServer } from '../../setup/serve.ts' + +const app = new Elysia() + .get('/health', () => ({ status: 'ok' })) + .use( + new Elysia() + .use(withSupabase({ auth: ['user', 'none'] })) + .get('/me-optional', ({ supabaseContext }) => ({ + userClaims: supabaseContext.userClaims, + })), + ) + .use( + new Elysia() + .use(withSupabase({ auth: 'user' })) + .get('/me', ({ supabaseContext }) => ({ + userClaims: supabaseContext.userClaims, + })) + .get('/notes', ({ supabaseContext }) => { + const { supabaseAdmin, userClaims } = supabaseContext + return listNotes(supabaseAdmin, userClaims!.id) + }) + // User-scoped client: RLS does the scoping, no WHERE clause. + .get('/my-notes', ({ supabaseContext }) => + listOwnNotes(supabaseContext.supabase), + ) + // Admin client, no filter: sees every user's rows regardless of caller. + .get('/all-notes', ({ supabaseContext }) => + listAllNotes(supabaseContext.supabaseAdmin), + ) + .post('/notes', async ({ supabaseContext, body, set }) => { + const { supabaseAdmin, userClaims } = supabaseContext + const payload = body as { body?: string } | null + if (!payload?.body) { + set.status = 400 + return { error: 'body required' } + } + const note = await insertNote( + supabaseAdmin, + userClaims!.id, + payload.body, + ) + set.status = 201 + return note + }), + ) + +export function start(port: number) { + return startFetchServer((req) => app.handle(req), port) +} diff --git a/e2e/apps/h3/app.ts b/e2e/apps/h3/app.ts new file mode 100644 index 0000000..bac7705 --- /dev/null +++ b/e2e/apps/h3/app.ts @@ -0,0 +1,91 @@ +// Minimal H3 app exercising @supabase/server from dist/ (not src/), with +// config read from process.env via resolveEnv() — no mocked env anywhere. +import { H3, defineHandler } from 'h3' + +import { withSupabase } from '../../../dist/adapters/h3/index.mjs' +import type { SupabaseContext } from '../../../dist/index.mjs' +import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts' +import { startFetchServer } from '../../setup/serve.ts' + +declare module 'h3' { + interface H3EventContext { + supabaseContext: SupabaseContext + } +} + +const requireUser = withSupabase({ auth: 'user' }) +const optionalUser = withSupabase({ auth: ['user', 'none'] }) + +const app = new H3() + +app.get('/health', () => ({ status: 'ok' })) + +app.get( + '/me', + defineHandler({ + middleware: [requireUser], + handler: (event) => ({ + userClaims: event.context.supabaseContext.userClaims, + }), + }), +) + +app.get( + '/me-optional', + defineHandler({ + middleware: [optionalUser], + handler: (event) => ({ + userClaims: event.context.supabaseContext.userClaims, + }), + }), +) + +app.get( + '/notes', + defineHandler({ + middleware: [requireUser], + handler: async (event) => { + const { supabaseAdmin, userClaims } = event.context.supabaseContext + return listNotes(supabaseAdmin, userClaims!.id) + }, + }), +) + +// User-scoped client: RLS does the scoping, no WHERE clause. +app.get( + '/my-notes', + defineHandler({ + middleware: [requireUser], + handler: (event) => listOwnNotes(event.context.supabaseContext.supabase), + }), +) + +// Admin client, no filter: sees every user's rows regardless of caller. +app.get( + '/all-notes', + defineHandler({ + middleware: [requireUser], + handler: (event) => + listAllNotes(event.context.supabaseContext.supabaseAdmin), + }), +) + +app.post( + '/notes', + defineHandler({ + middleware: [requireUser], + handler: async (event) => { + const { supabaseAdmin, userClaims } = event.context.supabaseContext + const { body } = (await event.req.json()) as { body?: string } + if (!body) { + return Response.json({ error: 'body required' }, { status: 400 }) + } + const note = await insertNote(supabaseAdmin, userClaims!.id, body) + return Response.json(note, { status: 201 }) + }, + }), +) + +export function start(port: number) { + return startFetchServer(app.fetch, port) +} diff --git a/e2e/apps/hono/app.ts b/e2e/apps/hono/app.ts new file mode 100644 index 0000000..6cf5d03 --- /dev/null +++ b/e2e/apps/hono/app.ts @@ -0,0 +1,52 @@ +// Minimal Hono app exercising @supabase/server from dist/ (not src/), with +// config read from process.env via resolveEnv() — no mocked env anywhere. +import { Hono } from 'hono' + +import { withSupabase } from '../../../dist/adapters/hono/index.mjs' +import type { SupabaseContext } from '../../../dist/index.mjs' +import { insertNote, listAllNotes, listNotes, listOwnNotes } from '../notes.ts' +import { startFetchServer } from '../../setup/serve.ts' + +type Env = { Variables: { supabaseContext: SupabaseContext } } + +const app = new Hono() + +app.get('/health', (c) => c.json({ status: 'ok' })) + +app.use('/me', withSupabase({ auth: 'user' })) +app.use('/me-optional', withSupabase({ auth: ['user', 'none'] })) +app.use('/notes', withSupabase({ auth: 'user' })) +app.use('/my-notes', withSupabase({ auth: 'user' })) +app.use('/all-notes', withSupabase({ auth: 'user' })) + +app.get('/me', (c) => c.json({ userClaims: c.var.supabaseContext.userClaims })) + +app.get('/me-optional', (c) => + c.json({ userClaims: c.var.supabaseContext.userClaims }), +) + +app.get('/notes', async (c) => { + const { supabaseAdmin, userClaims } = c.var.supabaseContext + return c.json(await listNotes(supabaseAdmin, userClaims!.id)) +}) + +// User-scoped client: RLS does the scoping, no WHERE clause. +app.get('/my-notes', async (c) => + c.json(await listOwnNotes(c.var.supabaseContext.supabase)), +) + +// Admin client, no filter: sees every user's rows regardless of caller. +app.get('/all-notes', async (c) => + c.json(await listAllNotes(c.var.supabaseContext.supabaseAdmin)), +) + +app.post('/notes', async (c) => { + const { supabaseAdmin, userClaims } = c.var.supabaseContext + const { body } = await c.req.json<{ body?: string }>() + if (!body) return c.json({ error: 'body required' }, 400) + return c.json(await insertNote(supabaseAdmin, userClaims!.id, body), 201) +}) + +export function start(port: number) { + return startFetchServer(app.fetch, port) +} diff --git a/e2e/apps/nestjs/app.ts b/e2e/apps/nestjs/app.ts new file mode 100644 index 0000000..d84d7b6 --- /dev/null +++ b/e2e/apps/nestjs/app.ts @@ -0,0 +1,84 @@ +// 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 { + BadRequestException, + 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, listAllNotes, listNotes, listOwnNotes } 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) + } + + // User-scoped client: RLS does the scoping, no WHERE clause. + @Get('my-notes') + @UseGuards(UserGuard) + listOwn(@SupabaseCtx() ctx: SupabaseContext) { + 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) + create(@Body() dto: { body?: string }, @SupabaseCtx() ctx: SupabaseContext) { + if (!dto?.body) throw new BadRequestException('body required') + 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..66f8f9b --- /dev/null +++ b/e2e/apps/notes.ts @@ -0,0 +1,69 @@ +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 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. + */ +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, + 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/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/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..38f86f4 --- /dev/null +++ b/e2e/scenarios.ts @@ -0,0 +1,152 @@ +// 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') + const forgedToken = inject('forgedToken') + + 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 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) + 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 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), + }) + 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('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) + 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([]) + }) + + 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. + 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/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 < { + 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 new file mode 100644 index 0000000..5a83197 --- /dev/null +++ b/e2e/setup/global-setup.ts @@ -0,0 +1,48 @@ +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 + } +} + +export default async function setup(project: TestProject) { + loadEnv() + + const url = process.env.SUPABASE_URL + const anonKey = + process.env.SUPABASE_ANON_KEY ?? process.env.SUPABASE_PUBLISHABLE_KEY + + try { + const res = await fetch(`${url}/auth/v1/health`, { + headers: { apikey: anonKey ?? '' }, + }) + if (!res.ok) throw new Error(`auth health returned ${res.status}`) + } catch (cause) { + throw new Error( + `Local Supabase stack is not reachable at ${url}. ` + + 'Run `supabase start` (in e2e/) and `pnpm gen:env`, then retry.', + { cause }, + ) + } + + // Two users: user1 drives the happy-path scenarios, user2 exists solely to + // prove data isolation (it must never insert notes). + 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/setup/load-env.ts b/e2e/setup/load-env.ts new file mode 100644 index 0000000..7b88b04 --- /dev/null +++ b/e2e/setup/load-env.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +/** + * Loads e2e/.env (written by `pnpm gen:env`) into process.env. + * + * Variables already present in process.env win, so CI or a developer can + * export values directly instead of generating the file. + */ +export function loadEnv(): void { + const envPath = fileURLToPath(new URL('../.env', import.meta.url)) + + let raw: string + try { + raw = readFileSync(envPath, 'utf8') + } catch { + if (process.env.SUPABASE_URL) return + throw new Error( + 'e2e/.env not found and SUPABASE_URL is not set. ' + + 'Start the local stack with `supabase start` (in e2e/), then run `pnpm gen:env`.', + ) + } + + for (const line of raw.split('\n')) { + const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim()) + if (!match) continue + const [, key, value] = match + if (!(key in process.env)) process.env[key] = value + } +} diff --git a/e2e/setup/serve.ts b/e2e/setup/serve.ts new file mode 100644 index 0000000..8010e00 --- /dev/null +++ b/e2e/setup/serve.ts @@ -0,0 +1,20 @@ +import { serve } from 'srvx' + +/** + * Serves a Web-standard fetch handler over a real `node:http` server. + * Used for the Hono, H3, and Elysia apps — Elysia in particular is Bun-first, + * but its `app.handle` is a plain fetch handler, so wrapping it behind a Node + * server means CI does not need Bun installed. + * + * @returns An async function that stops the server. + */ +export async function startFetchServer( + fetchHandler: (req: Request) => 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..bbe3398 --- /dev/null +++ b/e2e/setup/token.ts @@ -0,0 +1,55 @@ +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 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, + }) + if (signUpError) { + throw new Error( + `could not create test user ${email}: ${signUpError.message}`, + ) + } + 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'}`, + ) + } + + 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..81ef6fc --- /dev/null +++ b/e2e/supabase/migrations/20260101000000_notes.sql @@ -0,0 +1,24 @@ +-- A minimal per-user table for the data-access scenarios. The test apps use +-- 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), + body text not null check (char_length(body) <= 500), + created_at timestamptz not null default now() +); + +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. +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); 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, + }, + }, ], }, })