All checks were successful
Test and deploy staging / staging (push) Successful in 18s
Fold catchemall into /projects/game-tracker (old URL redirects) and add Call of Duty tracking for Black Ops through WWII. - Pokémon catalog is now owned: locations, encounters and per-game evolution methods are built from the pinned PokéAPI snapshot and seeded. - Pokémon you can evolve or breed in a game count as obtainable; mythicals without another route are marked event-only. - Detail dialog with evolution chain and where to find it, location pages with admin notes and pictures, and admin corrections that survive reseeds. - Call of Duty weapons, milestones and Steam achievements per game, with derived Diamond / Dark Matter / Solar / Black Sky / Chrome tiers. - New migration (db:migrate:game-tracker), seeds (db:seed, db:seed:cod) and deploy preflight checks for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
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<typeof postgres>;
|
|
|
|
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<string, string> = {}
|
|
) {
|
|
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();
|
|
}
|
|
});
|