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>
82 lines
2.2 KiB
TypeScript
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()
|
|
})
|
|
})
|