This commit is contained in:
246
tests/auth.test.ts
Normal file
246
tests/auth.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
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/catch-em-all');
|
||||
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/catch-em-all`);
|
||||
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/catch-em-all?/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/catch-em-all?/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/catch-em-all?/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/catch-em-all?/setGames', { gameIds: 'not-a-game' })).status()
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await action(bob, '/projects/catch-em-all?/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/catch-em-all?/togglePokemonCaught', {
|
||||
entryId: '1',
|
||||
caught: '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();
|
||||
}
|
||||
});
|
||||
28
tests/finder.test.ts
Normal file
28
tests/finder.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
const path = 'http://localhost:4173/projects/spotify-beatmap-finder';
|
||||
test('finder is responsive and validates enhanced submissions', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(path);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Spotify Map Finder', exact: true })
|
||||
).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(
|
||||
true
|
||||
);
|
||||
await page.getByLabel('Song or Spotify URL').fill('spotify:artist:6PUzxtIHkv346yP89NzP9X');
|
||||
await page.getByRole('button', { name: 'Search' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('Unsupported Spotify resource');
|
||||
await expect(page.getByLabel('Song or Spotify URL')).toHaveValue(
|
||||
'spotify:artist:6PUzxtIHkv346yP89NzP9X'
|
||||
);
|
||||
});
|
||||
test('finder retains native form behavior', async ({ browser }) => {
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
const page = await context.newPage();
|
||||
await page.goto(path);
|
||||
await page.getByLabel('Song or Spotify URL').fill('spotify:album:invalid');
|
||||
await page.getByRole('button', { name: 'Search' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('Invalid Spotify ID');
|
||||
await expect(page.getByLabel('Song or Spotify URL')).toHaveValue('spotify:album:invalid');
|
||||
await context.close();
|
||||
});
|
||||
Reference in New Issue
Block a user