// FE-PAGE-OAUTH-001 to FE-PAGE-OAUTH-012 import { render, screen, waitFor } from '../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../tests/helpers/msw/server'; import { useAuthStore } from '../store/authStore'; import { resetAllStores, seedStore } from '../../tests/helpers/store'; import { buildUser } from '../../tests/helpers/factories'; import OAuthAuthorizePage from './OAuthAuthorizePage'; // Default OAuth query params const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256'; function setSearchParams(search: string) { window.history.pushState({}, '', '/oauth/authorize' + search); } const VALIDATE_OK = { valid: true, client: { name: 'Test App', allowed_scopes: ['trips:read'] }, scopes: ['trips:read'], consentRequired: true, loginRequired: false, scopeSelectable: false, }; beforeEach(() => { resetAllStores(); setSearchParams(DEFAULT_SEARCH); server.resetHandlers(); // Default: authenticated user seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, isLoading: false }); // Default validate: consent required server.use( http.get('/api/oauth/authorize/validate', () => HttpResponse.json(VALIDATE_OK)), http.post('/api/oauth/authorize', () => HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=abc' }) ), ); }); afterEach(() => { window.history.pushState({}, '', '/'); }); describe('OAuthAuthorizePage', () => { it('FE-PAGE-OAUTH-001: shows loading spinner initially', () => { server.use( http.get('/api/oauth/authorize/validate', async () => { await new Promise(() => {}); // never resolves return HttpResponse.json(VALIDATE_OK); }) ); render(); expect(document.querySelector('.animate-spin')).toBeInTheDocument(); }); it('FE-PAGE-OAUTH-002: shows error state when validation fails', async () => { server.use( http.get('/api/oauth/authorize/validate', () => HttpResponse.json({ valid: false, error: 'invalid_client', error_description: 'Unknown client ID', }) ) ); render(); await screen.findByText('Authorization Error'); expect(screen.getByText('Unknown client ID')).toBeInTheDocument(); }); it('FE-PAGE-OAUTH-003: shows error state on network error', async () => { server.use( http.get('/api/oauth/authorize/validate', () => HttpResponse.json({ error: 'server error' }, { status: 500 }) ) ); render(); await screen.findByText('Authorization Error'); expect(screen.getByText(/Failed to validate/i)).toBeInTheDocument(); }); it('FE-PAGE-OAUTH-004: shows login_required state', async () => { server.use( http.get('/api/oauth/authorize/validate', () => HttpResponse.json({ ...VALIDATE_OK, loginRequired: true, consentRequired: true }) ) ); render(); await screen.findByText('Sign in to continue'); expect(screen.getByText('Sign in to TREK')).toBeInTheDocument(); }); it('FE-PAGE-OAUTH-005: shows client name in login_required state', async () => { server.use( http.get('/api/oauth/authorize/validate', () => HttpResponse.json({ ...VALIDATE_OK, loginRequired: true }) ) ); render(); await screen.findByText('Sign in to continue'); expect(screen.getByText(/Test App/)).toBeInTheDocument(); }); it('FE-PAGE-OAUTH-006: shows consent form with client name and scope list', async () => { render(); await screen.findByText('Test App'); expect(screen.getByText('Authorization Request')).toBeInTheDocument(); expect(screen.getByText('Approve Access')).toBeInTheDocument(); expect(screen.getByText('Deny')).toBeInTheDocument(); }); it('FE-PAGE-OAUTH-007: auto-approves when consentRequired is false', async () => { let authorizeCalled = false; server.use( http.get('/api/oauth/authorize/validate', () => HttpResponse.json({ ...VALIDATE_OK, consentRequired: false }) ), http.post('/api/oauth/authorize', async ({ request }) => { const body = await request.json() as Record; authorizeCalled = true; expect(body.approved).toBe(true); return HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=xyz' }); }) ); render(); // Shows auto-approving spinner await waitFor(() => { expect(authorizeCalled).toBe(true); }); }); it('FE-PAGE-OAUTH-008: clicking Deny sends approved=false to authorize', async () => { const user = userEvent.setup(); let body: Record = {}; server.use( http.post('/api/oauth/authorize', async ({ request }) => { body = await request.json() as Record; return HttpResponse.json({ redirect: 'http://localhost:4000/callback?error=access_denied' }); }) ); render(); await screen.findByText('Deny'); await user.click(screen.getByText('Deny')); await waitFor(() => { expect(body.approved).toBe(false); }); }); it('FE-PAGE-OAUTH-009: clicking Approve sends approved=true with selected scopes', async () => { const user = userEvent.setup(); let body: Record = {}; server.use( http.post('/api/oauth/authorize', async ({ request }) => { body = await request.json() as Record; return HttpResponse.json({ redirect: 'http://localhost:4000/callback?code=ok' }); }) ); render(); await screen.findByText('Approve Access'); await user.click(screen.getByText('Approve Access')); await waitFor(() => { expect(body.approved).toBe(true); }); }); it('FE-PAGE-OAUTH-010: shows error when authorize call fails', async () => { const user = userEvent.setup(); server.use( http.post('/api/oauth/authorize', () => HttpResponse.json({ error: 'server error' }, { status: 500 }) ) ); render(); await screen.findByText('Approve Access'); await user.click(screen.getByText('Approve Access')); await screen.findByText('Authorization Error'); expect(screen.getByText(/Authorization failed/i)).toBeInTheDocument(); }); it('FE-PAGE-OAUTH-011: scopeSelectable=true renders checkboxes for scopes', async () => { server.use( http.get('/api/oauth/authorize/validate', () => HttpResponse.json({ ...VALIDATE_OK, scopeSelectable: true, scopes: ['trips:read', 'places:read'] }) ) ); render(); await screen.findByText('Choose which permissions to grant'); expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0); }); it('FE-PAGE-OAUTH-012: scopeSelectable=false renders read-only scope list', async () => { render(); await screen.findByText('Permissions requested'); // No checkboxes in read-only mode expect(screen.queryAllByRole('checkbox')).toHaveLength(0); }); });