diff --git a/e2e/auth-sanity.spec.ts b/e2e/auth-sanity.spec.ts new file mode 100644 index 0000000..9528492 --- /dev/null +++ b/e2e/auth-sanity.spec.ts @@ -0,0 +1,74 @@ +import { test, expect, type Page } from '@playwright/test'; +import { setScenario } from './support/scenario.ts'; +import { resetMockStore } from './support/auth.ts'; +import { ADMIN_USER, SUPABASE_STORAGE_KEY } from './support/constants.ts'; +import { collect, expectClean, waitForShell } from './support/harness.ts'; + +test.beforeEach(async () => { + await setScenario('data'); + await resetMockStore(); +}); + +async function openMobileMenu(page: Page): Promise { + const toggle = page.getByRole('button', { name: 'Open menu' }); + if (await toggle.isVisible()) { + await toggle.click(); + } +} + +test('@sanity GitHub sign-in persists an admin session and logout revokes access', async ({ + page +}) => { + const errors = collect(page); + await page.goto('/'); + await waitForShell(page); + await openMobileMenu(page); + + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForURL(/\/auth\/callback/); + await page.waitForURL(/\/$/); + + await openMobileMenu(page); + await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Submit', exact: true })).toBeVisible(); + await expect + .poll(() => + page.evaluate( + ([key, accessToken]) => { + const raw = localStorage.getItem(key); + if (!raw) return false; + try { + return JSON.parse(raw).access_token === accessToken; + } catch { + return false; + } + }, + [SUPABASE_STORAGE_KEY, ADMIN_USER.accessToken] as const + ) + ) + .toBe(true); + + await page.reload(); + await waitForShell(page); + await openMobileMenu(page); + await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); + + await page.goto('/submit'); + await expect(page.getByLabel(/Problem URLs/i)).toBeVisible(); + await openMobileMenu(page); + + await page.getByRole('button', { name: 'Logout' }).click(); + await expect + .poll(() => page.evaluate((key) => localStorage.getItem(key), SUPABASE_STORAGE_KEY)) + .toBeNull(); + const mobileMenuToggle = page.getByRole('button', { name: 'Open menu' }); + if (await mobileMenuToggle.isVisible()) { + await mobileMenuToggle.click(); + } + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); + + await page.goto('/submit'); + await page.waitForURL(/\/$/); + await expect(page.getByLabel(/Problem URLs/i)).toHaveCount(0); + expectClean(errors, 'auth sanity lifecycle'); +}); diff --git a/e2e/submit-codeforces.spec.ts b/e2e/submit-codeforces.spec.ts index d72c20f..79bc1cc 100644 --- a/e2e/submit-codeforces.spec.ts +++ b/e2e/submit-codeforces.spec.ts @@ -185,7 +185,7 @@ test.describe('invalid entries cannot be submitted', () => { }); test.describe('single / batch / contest / duplicate paths', () => { - test('adds a single problem successfully', async ({ page }) => { + test('@sanity adds a single problem successfully', async ({ page }) => { await seedAdminSession(page); const errors = await gotoSubmit(page); diff --git a/e2e/submit-kattis.spec.ts b/e2e/submit-kattis.spec.ts index 80fdb4c..3d26904 100644 --- a/e2e/submit-kattis.spec.ts +++ b/e2e/submit-kattis.spec.ts @@ -78,7 +78,7 @@ test.describe('admin authorization gate', () => { }); test.describe('two-phase flow and submit paths', () => { - test('previewing writes nothing until the final confirm', async ({ page }) => { + test('@sanity previewing writes nothing until the final confirm', async ({ page }) => { await seedAdminSession(page); const errors = await gotoSubmit(page); diff --git a/e2e/support/mock-supabase.ts b/e2e/support/mock-supabase.ts index 9791e3f..7d1bc6e 100644 --- a/e2e/support/mock-supabase.ts +++ b/e2e/support/mock-supabase.ts @@ -26,8 +26,10 @@ // POST /rest/v1/rpc/get_leaderboard -> array of leaderboard rows // // Auth endpoints (GoTrue subset): +// GET /auth/v1/authorize -> deterministic GitHub OAuth callback // GET /auth/v1/user -> the user for the Bearer token // POST /auth/v1/token?grant_type=... -> a refreshed session (deterministic) +// POST /auth/v1/logout -> successful session revocation // // Authenticated submit-path endpoints (bounded, in-memory, isolated): // GET /rest/v1/user_roles?user_id=eq. -> the caller's role row(s) @@ -158,6 +160,42 @@ function eqFilter(url: URL, col: string): string | null { } // --- Auth (GoTrue) ----------------------------------------------------------- +// Return the implicit-flow callback URL that Supabase would receive after a +// successful GitHub OAuth exchange. The browser client follows this URL and +// persists the hash session before the callback route forwards home. +function handleAuthAuthorize(res: http.ServerResponse, url: URL): void { + const redirect = url.searchParams.get('redirect_to'); + if (!redirect) { + sendJson(res, 400, { message: 'missing redirect_to' }); + return; + } + + let callback: URL; + try { + callback = new URL(redirect); + } catch { + sendJson(res, 400, { message: 'invalid redirect_to' }); + return; + } + + if (callback.hostname !== 'localhost' || callback.pathname !== '/auth/callback') { + sendJson(res, 400, { message: 'redirect_to is not the test callback' }); + return; + } + + const expiresIn = 60 * 60; + callback.hash = new URLSearchParams({ + access_token: ADMIN_USER.accessToken, + expires_in: String(expiresIn), + expires_at: String(Math.floor(Date.now() / 1000) + expiresIn), + refresh_token: ADMIN_USER.refreshToken, + token_type: 'bearer', + provider_token: 'e2e-github-provider-token' + }).toString(); + res.writeHead(302, { Location: callback.toString() }); + res.end(); +} + // GET /auth/v1/user resolves the Bearer token to a seeded user. The server-side // admin recheck calls supabase.auth.getUser(token); an unknown/absent token // yields a 401 exactly as GoTrue would. @@ -459,6 +497,11 @@ const server = http.createServer(async (req, res) => { } // Auth (GoTrue) ------------------------------------------------------------- + if (url.pathname === '/auth/v1/authorize') { + await readBody(req); + handleAuthAuthorize(res, url); + return; + } if (url.pathname === '/auth/v1/user') { await readBody(req); handleAuthUser(req, res); @@ -469,6 +512,18 @@ const server = http.createServer(async (req, res) => { handleAuthToken(res, body); return; } + if (url.pathname === '/auth/v1/logout') { + await readBody(req); + // GoTrue accepts a scope query parameter; this deterministic mock only + // needs to acknowledge the request because the client clears local state. + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Methods': 'POST,OPTIONS' + }); + res.end(); + return; + } // Provider (upstream) stubs ------------------------------------------------- if (url.pathname === '/api/problemset.problems') { diff --git a/package.json b/package.json index 73ca019..38547a2 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "lint:es": "eslint .", "format": "prettier --write .", "test": "node --test 'tests/**/*.test.ts'", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:sanity": "playwright test --grep @sanity" }, "devDependencies": { "supabase": "^2.109.1",