Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions e2e/auth-sanity.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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');
});
2 changes: 1 addition & 1 deletion e2e/submit-codeforces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion e2e/submit-kattis.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
55 changes: 55 additions & 0 deletions e2e/support/mock-supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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') {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading