import { test, expect, type APIRequestContext } from '@playwright/test'; import postgres from 'postgres'; import { execFileSync } from 'node:child_process'; // Opt-in: these tests create and clean up their own rows in the configured dev DB. test.skip( !process.env.AUTH_E2E, 'Run npm run test:auth against a migrated, seeded development database' ); const origin = 'http://127.0.0.1:4174'; const password = 'A-long-test-password-2026!'; const createdUsers: string[] = []; const createdEmails: string[] = []; const createdLists: number[] = []; const createdArticles: number[] = []; let sql: ReturnType; test.beforeAll(() => { if (!process.env.DB_URL) throw new Error('DB_URL is required'); sql = postgres(process.env.DB_URL, { max: 1 }); }); test.afterAll(async () => { if (!sql) return; for (const id of createdLists) await sql`delete from lists where id = ${id}`; for (const id of createdArticles) await sql`delete from blog_articles where id = ${id}`; for (const id of createdUsers) await sql`delete from "user" where id = ${id}`; for (const email of createdEmails) await sql`delete from "user" where email = ${email}`; await sql.end(); }); async function register(request: APIRequestContext, name: string) { const email = `auth-test-${crypto.randomUUID()}@example.com`; createdEmails.push(email); const response = await request.post('/api/auth/sign-up/email', { headers: { Origin: origin }, data: { name, email, password, role: 'admin' } }); expect(response.ok(), await response.text()).toBe(true); const data = await response.json(); createdUsers.push(data.user.id); expect(data.user.role).toBe('user'); return { id: data.user.id as string, email }; } async function action( request: APIRequestContext, path: string, values: Record = {} ) { const response = await request.post(path, { headers: { Origin: origin, Accept: 'application/json', 'x-sveltekit-action': 'true' }, form: values, maxRedirects: 0 }); const result = await response.json().catch(() => null); return { status: () => result?.status ?? response.status(), headers: () => ({ ...response.headers(), location: result?.location ?? response.headers().location }) }; } test('register, persist progress, isolate users, enforce admin, revoke session', async ({ page, playwright }) => { const errors: string[] = []; page.on('pageerror', (error) => errors.push(error.message)); const email = `auth-test-${crypto.randomUUID()}@example.com`; createdEmails.push(email); await page.goto('/register?returnTo=/projects/game-tracker/pokemon'); await page.getByLabel('Name', { exact: true }).fill('Browser Trainer'); await page.getByRole('textbox', { name: 'Email', exact: true }).fill(email); await page.getByLabel('Password', { exact: true }).fill(password); await page.getByRole('button', { name: 'Create account', exact: true }).click(); await expect(page).toHaveURL(`${origin}/projects/game-tracker/pokemon`); const session = await (await page.request.get('/api/auth/get-session')).json(); createdUsers.push(session.user.id); expect(session.user.role).toBe('user'); await expect(page.getByRole('link', { name: 'Browser Trainer', exact: true })).toBeVisible(); await page.screenshot({ path: '/tmp/personal-website-catchemall.png' }); const firstCatch = page.getByRole('checkbox', { name: /^Caught / }).first(); await firstCatch.check(); await expect(firstCatch).toBeEnabled(); await page.reload(); await expect(page.getByRole('checkbox', { name: /^Caught / }).first()).toBeChecked(); const [caught] = await sql`select pokemon_game_entry_id from user_entries where user_id = ${session.user.id}`; expect(caught).toBeTruthy(); const [{ count: beforeCount }] = await sql`select count(*) from pokemon_game_entries`; execFileSync('bun', ['scripts/seed-catalog.ts'], { env: process.env }); expect((await sql`select count(*) from pokemon_game_entries`)[0].count).toBe(beforeCount); expect( ( await sql`select pokemon_game_entry_id from user_entries where user_id = ${session.user.id}` )[0] ).toEqual(caught); const [game] = await sql`select id from games order by id limit 1`; const selection = await action(page.request, '/projects/game-tracker/pokemon?/setGames', { gameIds: String(game.id) }); expect(selection.status()).toBe(200); await page.reload(); await expect(page.locator('thead th')).toHaveCount(2); // Explicitly selecting none must not revert to all games after a reload. expect((await action(page.request, '/projects/game-tracker/pokemon?/setGames')).status()).toBe( 200 ); await page.reload(); await expect(page.locator('thead th')).toHaveCount(1); const bob = await playwright.request.newContext({ baseURL: origin }); try { const other = await register(bob, 'Other Trainer'); expect( ( await action(bob, '/projects/game-tracker/pokemon?/togglePokemonCaught', { entryId: String(caught.pokemon_game_entry_id) }) ).status() ).toBe(200); expect((await sql`select * from user_entries where user_id = ${session.user.id}`).length).toBe( 1 ); expect((await sql`select * from user_entries where user_id = ${other.id}`).length).toBe(0); expect((await action(bob, '/lists?/newList')).status()).toBe(403); expect((await action(bob, '/blog', { title: 'Not allowed' })).status()).toBe(403); expect((await action(bob, '/projects/progress/create', { name: 'Not allowed' })).status()).toBe( 403 ); expect( ( await bob.post('/projects/animaldex/api/animals/1/moderate', { headers: { Origin: origin }, data: { action: 'accept' } }) ).status() ).toBe(403); expect( ( await action(bob, '/projects/game-tracker/pokemon?/setGames', { gameIds: 'not-a-game' }) ).status() ).toBe(400); expect( ( await action(bob, '/projects/game-tracker/pokemon?/togglePokemonCaught', { entryId: '999999999' }) ).status() ).toBe(404); } finally { await bob.dispose(); } await sql`update "user" set role = 'admin' where id = ${session.user.id}`; await page.goto('/blog'); await expect(page.getByRole('button', { name: 'Write new article' })).toBeVisible(); const listResponse = await action(page.request, '/lists?/newList'); expect(listResponse.status()).toBe(303); const listId = Number(listResponse.headers().location?.split('/').pop()); createdLists.push(listId); await page.goto(`/lists/${listId}`); await page.getByLabel('List name').fill('Auth test list'); const renameResponse = page.waitForResponse( (response) => response.url().includes('/lists/') && response.request().method() === 'POST' ); await page.getByRole('button', { name: 'Rename', exact: true }).click(); const renamed = await renameResponse; expect(renamed.status()).toBe(200); expect((await sql`select name from lists where id = ${listId}`)[0].name).toBe('Auth test list'); await page.screenshot({ path: '/tmp/personal-website-list.png' }); await expect(page.getByRole('heading', { name: 'Auth test list' })).toBeVisible(); const title = `Auth test article ${crypto.randomUUID()}`; const articleResponse = await action(page.request, '/blog', { title }); expect(articleResponse.status()).toBe(303); const [article] = await sql`select id, slug from blog_articles where title = ${title}`; createdArticles.push(article.id); await page.goto(`/blog/${article.slug}/edit`); await expect(page.getByRole('button', { name: 'Save draft', exact: true }).first()).toBeVisible(); await sql`update "user" set role = 'user' where id = ${session.user.id}`; expect((await action(page.request, '/lists?/newList')).status()).toBe(403); const oldCookies = await page.context().cookies(); await page.goto('/account'); await page.getByRole('button', { name: 'Log out', exact: true }).last().click(); await expect(page).toHaveURL(/\/login$/); const replay = await playwright.request.newContext({ baseURL: origin, extraHTTPHeaders: { Cookie: oldCookies.map((c) => `${c.name}=${c.value}`).join('; ') } }); try { expect(await (await replay.get('/api/auth/get-session')).json()).toBeNull(); } finally { await replay.dispose(); } await page.getByRole('textbox', { name: 'Email', exact: true }).fill(email); await page.getByLabel('Password', { exact: true }).fill(password); await page.getByRole('button', { name: 'Log in', exact: true }).click(); await expect(page).toHaveURL(/\/account$/); expect(errors).toEqual([]); }); test('anonymous/forged cookies, reserved identities, unsafe redirects, OAuth start', async ({ page }) => { await page.context().addCookies([ { name: 'admin_token', value: 'anything', url: origin }, { name: 'token', value: 'anything', url: origin } ]); expect((await action(page.request, '/lists?/newList')).status()).toBe(401); expect( ( await action(page.request, '/projects/game-tracker/pokemon?/togglePokemonCaught', { entryId: '1', caught: 'on' }) ).status() ).toBe(401); expect( ( await action(page.request, '/projects/game-tracker/call-of-duty/black-ops-2?/toggle', { itemId: '1', milestone: 'gold', done: 'on' }) ).status() ).toBe(401); expect( (await page.request.post('/admin/login', { data: { password: 'anything' } })).status() ).toBe(410); const reserved = await page.request.post('/api/auth/sign-up/email', { headers: { Origin: origin }, data: { name: 'Fake osu', email: '123@osu.invalid', password } }); expect(reserved.status()).toBe(400); const crossOrigin = await page.request.post('/api/auth/sign-in/email', { headers: { Origin: 'https://evil.example' }, data: { name: 'Fake', email: 'cross-origin@example.com', password } }); expect(crossOrigin.status()).toBe(403); expect( (await page.request.post('/logout', { headers: { Origin: 'https://evil.example' } })).status() ).toBe(403); await page.goto('/login?returnTo=https://evil.example'); await expect(page.getByRole('link', { name: 'Create an account' })).toHaveAttribute( 'href', '/register?returnTo=%2Faccount' ); if (process.env.OSU_CLIENT_ID && process.env.OSU_CLIENT_SECRET) { const response = await page.request.post('/api/auth/sign-in/social', { headers: { Origin: origin }, data: { provider: 'osu', callbackURL: '/account', disableRedirect: true } }); expect(response.ok(), await response.text()).toBe(true); const url = new URL((await response.json()).url); expect(url.origin).toBe('https://osu.ppy.sh'); expect(url.searchParams.get('scope')).toBe('identify'); expect(url.searchParams.get('redirect_uri')).toBe(`${origin}/api/auth/callback/osu`); expect(url.searchParams.get('state')).toBeTruthy(); } });