diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df18539..b24cf2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,6 @@ jobs: run: | pnpm run build - # NOTE: uncomment when we have tests - # - name: Test - # run: | - # pnpm run test -- --coverage + - name: Test + run: | + pnpm run test -- --run --coverage diff --git a/example/src/hooks.server.test.ts b/example/src/hooks.server.test.ts new file mode 100644 index 0000000..5712b54 --- /dev/null +++ b/example/src/hooks.server.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RequestEvent } from '@sveltejs/kit'; + +vi.mock('$env/dynamic/private', () => ({ + env: { + WORKOS_CLIENT_ID: 'client-id', + WORKOS_API_KEY: 'api-key', + WORKOS_REDIRECT_URI: 'https://example.test/callback', + WORKOS_COOKIE_PASSWORD: 'cookie-password', + }, +})); + +vi.mock('$app/environment', () => ({ dev: false })); + +vi.mock('@workos/authkit-sveltekit', () => ({ + configureAuthKit: vi.fn(), + authKitHandle: vi.fn(() => vi.fn()), +})); + +import { protectedRoutesHandle } from './hooks.server.js'; + +function eventFor({ + pathname, + routeId, + authenticated = false, +}: { + pathname: string; + routeId: string | null; + authenticated?: boolean; +}): RequestEvent { + const url = new URL(`https://example.test${pathname}`); + + return { + url, + route: { id: routeId }, + locals: authenticated ? { auth: { user: { id: 'user_01' } } } : {}, + } as RequestEvent; +} + +async function runRoute(options: Parameters[0]) { + const event = eventFor(options); + const resolve = vi.fn(async () => new Response('resolved')); + + const result = protectedRoutesHandle({ event, resolve } as Parameters[0]); + + return { result, resolve }; +} + +describe('example protectedRoutesHandle', () => { + it.each([ + ['/account', '/account'], + ['/%61ccount', '/account'], + ])('redirects unauthenticated account requests for %s', async (pathname, routeId) => { + const { result, resolve } = await runRoute({ pathname, routeId }); + + await expect(result).rejects.toMatchObject({ + status: 302, + location: `/login?returnPathname=${encodeURIComponent(pathname)}`, + }); + expect(resolve).not.toHaveBeenCalled(); + }); + + it.each([ + ['/api/get-name', '/api/get-name'], + ['/%61pi/get-name', '/api/get-name'], + ])('returns 401 for unauthenticated API requests for %s', async (pathname, routeId) => { + const { result, resolve } = await runRoute({ pathname, routeId }); + const response = await result; + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }); + expect(resolve).not.toHaveBeenCalled(); + }); + + it.each([ + ['/account', '/account'], + ['/api/get-name', '/api/get-name'], + ])('resolves authenticated protected requests for %s', async (pathname, routeId) => { + const { result, resolve } = await runRoute({ pathname, routeId, authenticated: true }); + + await expect(result).resolves.toMatchObject({ status: 200 }); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['/', '/'], + ['/login', '/login'], + ['/not-a-route', null], + ])('resolves public and unmatched requests for %s', async (pathname, routeId) => { + const { result, resolve } = await runRoute({ pathname, routeId }); + + await expect(result).resolves.toMatchObject({ status: 200 }); + expect(resolve).toHaveBeenCalledTimes(1); + }); +}); diff --git a/example/src/hooks.server.ts b/example/src/hooks.server.ts index 3c255a6..e21d1f4 100644 --- a/example/src/hooks.server.ts +++ b/example/src/hooks.server.ts @@ -18,13 +18,22 @@ const authHandle = authKitHandle({ }); // Create a custom handle for protected routes -const protectedRoutesHandle: Handle = async ({ event, resolve }) => { +export const protectedRoutesHandle: Handle = async ({ event, resolve }) => { const protectedPaths = ['/account', '/api/'] as const; - const isProtectedRoute = protectedPaths.some((path) => event.url.pathname.startsWith(path)); + // Guard on the resolved route id rather than the raw URL pathname. + // SvelteKit decodes the pathname before matching routes, so a check + // against event.url.pathname (which preserves percent-encoding) can be + // bypassed with an encoded path such as /%61ccount while the router still + // dispatches the protected /account route. event.route.id reflects the + // matched route and is immune to encoding tricks. + const routeId = event.route.id; + const isProtectedRoute = + routeId != null && protectedPaths.some((path) => routeId === path || routeId.startsWith(path)); + const isApiRoute = routeId != null && routeId.startsWith('/api/'); if (isProtectedRoute && !event.locals.auth?.user) { // API routes should return 401 - if (event.url.pathname.startsWith('/api/')) { + if (isApiRoute) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' },