Files
resolutionflow/frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx
Michael Chihlas 70ab1f34d4 feat(auth): redesign /register with OAuth buttons; hide invite-code under flag
Phase 2 Task 35. Adds OAuth Google/Microsoft sign-in to the register flow,
gated on the public SELF_SERVE_ENABLED flag, and hides the legacy invite-code
field when self-serve is on.

- New `useAppConfig` hook + `configApi`. One-shot module-cached fetch of
  `GET /api/v1/config/public`; falls back to `VITE_SELF_SERVE_ENABLED` env
  var (default false) if the endpoint is unreachable.
- New `OAuthCallbackPage` mounted at `/auth/google/callback` and
  `/auth/microsoft/callback` (public, NOT inside ProtectedRoute). Posts the
  authorization code to the backend, persists tokens, hydrates the auth
  store via fetchUser, and redirects to `/welcome` (new) or `/` (returning).
- `RegisterPage` now renders OAuth buttons + email/password divider when
  `self_serve_enabled` is true and only emits buttons for providers the
  backend reports as configured. Invite-code field hidden in that mode.
  Captures `?plan=pro` into `localStorage.rf-intended-plan` on mount.
- `authApi` gains `googleCallback(code)` / `microsoftCallback(code)`.
- `frontend/.env.example` + `frontend/Dockerfile` document and bake the
  three new VITE_* build-time variables (Lesson 60: Vite needs ARG+ENV).
- Vitest coverage for the three required cases plus the plan-param capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:15:25 -04:00

82 lines
2.2 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import { OAuthCallbackPage } from '../OAuthCallbackPage'
import { authApi } from '@/api/auth'
vi.mock('@/api/auth', () => ({
authApi: {
googleCallback: vi.fn(),
microsoftCallback: vi.fn(),
},
}))
vi.mock('@/store/authStore', () => ({
useAuthStore: () => ({
setTokens: vi.fn(),
fetchUser: vi.fn().mockResolvedValue(undefined),
}),
}))
function renderAt(path: string) {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/auth/google/callback"
element={<OAuthCallbackPage />}
/>
<Route
path="/auth/microsoft/callback"
element={<OAuthCallbackPage />}
/>
</Routes>
</MemoryRouter>
</HelmetProvider>,
)
}
describe('OAuthCallbackPage CSRF state validation', () => {
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
})
afterEach(() => {
sessionStorage.clear()
})
it('shows error and does NOT call googleCallback when state in URL does not match sessionStorage', async () => {
sessionStorage.setItem('rf-oauth-state', 'expected-state-value')
renderAt('/auth/google/callback?code=auth-code-123&state=attacker-state')
await waitFor(() => {
expect(
screen.getByText(/Invalid OAuth state/i),
).toBeInTheDocument()
})
expect(authApi.googleCallback).not.toHaveBeenCalled()
expect(authApi.microsoftCallback).not.toHaveBeenCalled()
// Stored value must be cleared regardless of outcome.
expect(sessionStorage.getItem('rf-oauth-state')).toBeNull()
})
it('shows error and does NOT call googleCallback when stored state is missing', async () => {
// No sessionStorage entry set.
renderAt('/auth/google/callback?code=auth-code-123&state=any-state')
await waitFor(() => {
expect(
screen.getByText(/Invalid OAuth state/i),
).toBeInTheDocument()
})
expect(authApi.googleCallback).not.toHaveBeenCalled()
})
})