- 新增 .gitlab-ci.yml (lint/test/build 三阶段) - 新增前端测试: taskStageMapper (109), api.ts (36), AuthContext (16) - 修复旧测试: Sidebar 导航文案、MobileLayout padding 值 - python-jose → PyJWT 消除 ecdsa CVE 漏洞 - 限流中间件增加 5 个敏感端点精细限流 + 标准限流头 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
/**
|
|
* AuthContext Unit Tests
|
|
*
|
|
* Tests the AuthProvider, useAuth hook, and the exported USE_MOCK flag.
|
|
* Covers: initial state, login (mock + real), logout, register, and
|
|
* restoration of user from localStorage on mount.
|
|
*/
|
|
import React from 'react'
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { renderHook, act, waitFor } from '@testing-library/react'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Use vi.hoisted to create mock functions before vi.mock hoisting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const { mockApiLogin, mockApiRegister, mockClearTokens, mockGetAccessToken } = vi.hoisted(() => ({
|
|
mockApiLogin: vi.fn(),
|
|
mockApiRegister: vi.fn(),
|
|
mockClearTokens: vi.fn(),
|
|
mockGetAccessToken: vi.fn(() => null),
|
|
}))
|
|
|
|
vi.mock('@/lib/api', () => ({
|
|
api: {
|
|
login: mockApiLogin,
|
|
register: mockApiRegister,
|
|
},
|
|
clearTokens: mockClearTokens,
|
|
getAccessToken: mockGetAccessToken,
|
|
setTokens: vi.fn(),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import AuthContext after mocks are set up
|
|
// ---------------------------------------------------------------------------
|
|
import { AuthProvider, useAuth, USE_MOCK } from './AuthContext'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function mockLocalStorage() {
|
|
const store: Record<string, string> = {}
|
|
return {
|
|
getItem: vi.fn((key: string) => store[key] ?? null),
|
|
setItem: vi.fn((key: string, value: string) => {
|
|
store[key] = value
|
|
}),
|
|
removeItem: vi.fn((key: string) => {
|
|
delete store[key]
|
|
}),
|
|
clear: vi.fn(() => {
|
|
Object.keys(store).forEach((k) => delete store[k])
|
|
}),
|
|
get length() {
|
|
return Object.keys(store).length
|
|
},
|
|
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
|
|
}
|
|
}
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
<AuthProvider>{children}</AuthProvider>
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AuthContext', () => {
|
|
let storage: ReturnType<typeof mockLocalStorage>
|
|
|
|
beforeEach(() => {
|
|
storage = mockLocalStorage()
|
|
Object.defineProperty(window, 'localStorage', { value: storage, writable: true })
|
|
vi.clearAllMocks()
|
|
// Default: no stored token
|
|
mockGetAccessToken.mockReturnValue(null)
|
|
})
|
|
|
|
afterEach(() => {
|
|
storage.clear()
|
|
})
|
|
|
|
// ---- USE_MOCK flag ----
|
|
|
|
describe('USE_MOCK export', () => {
|
|
it('is exported as a boolean', () => {
|
|
expect(typeof USE_MOCK).toBe('boolean')
|
|
})
|
|
|
|
it('is true in test/development environment or based on env var', () => {
|
|
// vitest runs with NODE_ENV=test. The AuthContext checks for
|
|
// NODE_ENV === 'development' or NEXT_PUBLIC_USE_MOCK === 'true'.
|
|
// We verify the export exists and is a boolean.
|
|
expect([true, false]).toContain(USE_MOCK)
|
|
})
|
|
})
|
|
|
|
// ---- Initial state ----
|
|
|
|
describe('initial state', () => {
|
|
it('starts with user as null', async () => {
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
expect(result.current.user).toBeNull()
|
|
})
|
|
|
|
it('starts with isAuthenticated as false', async () => {
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
expect(result.current.isAuthenticated).toBe(false)
|
|
})
|
|
|
|
it('starts with isLoading true, then becomes false', async () => {
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
})
|
|
})
|
|
|
|
// ---- Restoring user from localStorage ----
|
|
|
|
describe('restoring user from localStorage', () => {
|
|
it('restores user when token and stored user exist', async () => {
|
|
const storedUser = {
|
|
id: 'u1',
|
|
name: 'Restored User',
|
|
email: 'restored@test.com',
|
|
role: 'brand',
|
|
is_verified: true,
|
|
}
|
|
|
|
mockGetAccessToken.mockReturnValue('valid-token')
|
|
storage.setItem('miaosi_user', JSON.stringify(storedUser))
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
expect(result.current.user).toEqual(storedUser)
|
|
expect(result.current.isAuthenticated).toBe(true)
|
|
})
|
|
|
|
it('does not restore user when token is missing', async () => {
|
|
const storedUser = { id: 'u1', name: 'No Token', role: 'brand', is_verified: true }
|
|
storage.setItem('miaosi_user', JSON.stringify(storedUser))
|
|
mockGetAccessToken.mockReturnValue(null)
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
expect(result.current.user).toBeNull()
|
|
expect(result.current.isAuthenticated).toBe(false)
|
|
})
|
|
|
|
it('clears invalid JSON from localStorage gracefully', async () => {
|
|
mockGetAccessToken.mockReturnValue('some-token')
|
|
storage.setItem('miaosi_user', 'not-valid-json{{{')
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
expect(mockClearTokens).toHaveBeenCalled()
|
|
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_user')
|
|
expect(result.current.user).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ---- Login ----
|
|
|
|
describe('login', () => {
|
|
// When USE_MOCK is true, the AuthProvider uses mock login logic.
|
|
// When false, it calls api.login().
|
|
|
|
if (!USE_MOCK) {
|
|
it('calls api.login and sets user on success (real API path)', async () => {
|
|
const loginResponse = {
|
|
access_token: 'at',
|
|
refresh_token: 'rt',
|
|
token_type: 'bearer',
|
|
expires_in: 900,
|
|
user: {
|
|
id: 'u1',
|
|
name: 'API User',
|
|
email: 'api@test.com',
|
|
role: 'brand',
|
|
is_verified: true,
|
|
},
|
|
}
|
|
mockApiLogin.mockResolvedValueOnce(loginResponse)
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
let loginResult: { success: boolean; error?: string }
|
|
await act(async () => {
|
|
loginResult = await result.current.login({
|
|
email: 'api@test.com',
|
|
password: 'pass123',
|
|
})
|
|
})
|
|
|
|
expect(loginResult!.success).toBe(true)
|
|
expect(result.current.user?.name).toBe('API User')
|
|
expect(result.current.isAuthenticated).toBe(true)
|
|
expect(storage.setItem).toHaveBeenCalledWith(
|
|
'miaosi_user',
|
|
expect.stringContaining('API User')
|
|
)
|
|
})
|
|
|
|
it('returns error on api.login failure (real API path)', async () => {
|
|
mockApiLogin.mockRejectedValueOnce(new Error('Invalid credentials'))
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
let loginResult: { success: boolean; error?: string }
|
|
await act(async () => {
|
|
loginResult = await result.current.login({
|
|
email: 'bad@test.com',
|
|
password: 'wrong',
|
|
})
|
|
})
|
|
|
|
expect(loginResult!.success).toBe(false)
|
|
expect(loginResult!.error).toBe('Invalid credentials')
|
|
expect(result.current.user).toBeNull()
|
|
})
|
|
}
|
|
|
|
if (USE_MOCK) {
|
|
it('logs in with mock user for known email (mock path)', async () => {
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
let loginResult: { success: boolean; error?: string }
|
|
await act(async () => {
|
|
loginResult = await result.current.login({
|
|
email: 'creator@demo.com',
|
|
password: 'demo123',
|
|
})
|
|
})
|
|
|
|
expect(loginResult!.success).toBe(true)
|
|
expect(result.current.user?.email).toBe('creator@demo.com')
|
|
expect(result.current.isAuthenticated).toBe(true)
|
|
})
|
|
|
|
it('returns error for unknown email in mock mode', async () => {
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
let loginResult: { success: boolean; error?: string }
|
|
await act(async () => {
|
|
loginResult = await result.current.login({
|
|
email: 'unknown@demo.com',
|
|
password: 'demo123',
|
|
})
|
|
})
|
|
|
|
expect(loginResult!.success).toBe(false)
|
|
expect(loginResult!.error).toBeDefined()
|
|
})
|
|
|
|
it('returns error for wrong password in mock mode', async () => {
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
let loginResult: { success: boolean; error?: string }
|
|
await act(async () => {
|
|
loginResult = await result.current.login({
|
|
email: 'creator@demo.com',
|
|
password: 'wrongpassword',
|
|
})
|
|
})
|
|
|
|
expect(loginResult!.success).toBe(false)
|
|
expect(loginResult!.error).toBe('密码错误')
|
|
})
|
|
|
|
it('allows email code login for known user in mock mode', async () => {
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
let loginResult: { success: boolean; error?: string }
|
|
await act(async () => {
|
|
loginResult = await result.current.login({
|
|
email: 'brand@demo.com',
|
|
email_code: '123456',
|
|
})
|
|
})
|
|
|
|
expect(loginResult!.success).toBe(true)
|
|
expect(result.current.user?.email).toBe('brand@demo.com')
|
|
expect(result.current.user?.role).toBe('brand')
|
|
})
|
|
}
|
|
})
|
|
|
|
// ---- Register ----
|
|
|
|
describe('register', () => {
|
|
const registerData = {
|
|
email: 'new@example.com',
|
|
password: 'secure123',
|
|
name: 'New User',
|
|
role: 'creator' as const,
|
|
email_code: '123456',
|
|
}
|
|
|
|
if (!USE_MOCK) {
|
|
it('calls api.register and sets user on success (real API path)', async () => {
|
|
const registerResponse = {
|
|
access_token: 'at',
|
|
refresh_token: 'rt',
|
|
token_type: 'bearer',
|
|
expires_in: 900,
|
|
user: {
|
|
id: 'u2',
|
|
name: 'New User',
|
|
email: 'new@example.com',
|
|
role: 'creator',
|
|
is_verified: false,
|
|
},
|
|
}
|
|
mockApiRegister.mockResolvedValueOnce(registerResponse)
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
let registerResult: { success: boolean; error?: string }
|
|
await act(async () => {
|
|
registerResult = await result.current.register(registerData)
|
|
})
|
|
|
|
expect(registerResult!.success).toBe(true)
|
|
expect(result.current.user?.name).toBe('New User')
|
|
expect(result.current.isAuthenticated).toBe(true)
|
|
})
|
|
|
|
it('returns error on api.register failure', async () => {
|
|
mockApiRegister.mockRejectedValueOnce(new Error('Email already exists'))
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
let registerResult: { success: boolean; error?: string }
|
|
await act(async () => {
|
|
registerResult = await result.current.register(registerData)
|
|
})
|
|
|
|
expect(registerResult!.success).toBe(false)
|
|
expect(registerResult!.error).toBe('Email already exists')
|
|
})
|
|
}
|
|
|
|
if (USE_MOCK) {
|
|
it('creates a mock user on register in mock mode', async () => {
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
let registerResult: { success: boolean; error?: string }
|
|
await act(async () => {
|
|
registerResult = await result.current.register(registerData)
|
|
})
|
|
|
|
expect(registerResult!.success).toBe(true)
|
|
expect(result.current.user?.email).toBe('new@example.com')
|
|
expect(result.current.user?.name).toBe('New User')
|
|
expect(result.current.user?.role).toBe('creator')
|
|
expect(result.current.isAuthenticated).toBe(true)
|
|
})
|
|
}
|
|
})
|
|
|
|
// ---- Logout ----
|
|
|
|
describe('logout', () => {
|
|
it('clears user state, tokens, and localStorage', async () => {
|
|
const storedUser = {
|
|
id: 'u1',
|
|
name: 'Logged In',
|
|
email: 'in@test.com',
|
|
role: 'brand',
|
|
is_verified: true,
|
|
}
|
|
mockGetAccessToken.mockReturnValue('valid-token')
|
|
storage.setItem('miaosi_user', JSON.stringify(storedUser))
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
expect(result.current.user).not.toBeNull()
|
|
expect(result.current.isAuthenticated).toBe(true)
|
|
|
|
act(() => {
|
|
result.current.logout()
|
|
})
|
|
|
|
expect(result.current.user).toBeNull()
|
|
expect(result.current.isAuthenticated).toBe(false)
|
|
expect(mockClearTokens).toHaveBeenCalled()
|
|
expect(storage.removeItem).toHaveBeenCalledWith('miaosi_user')
|
|
})
|
|
})
|
|
|
|
// ---- switchRole ----
|
|
|
|
describe('switchRole', () => {
|
|
it('updates the user role and persists to localStorage', async () => {
|
|
const storedUser = {
|
|
id: 'u1',
|
|
name: 'Multi Role',
|
|
email: 'multi@test.com',
|
|
role: 'brand',
|
|
is_verified: true,
|
|
}
|
|
mockGetAccessToken.mockReturnValue('valid-token')
|
|
storage.setItem('miaosi_user', JSON.stringify(storedUser))
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
expect(result.current.user?.role).toBe('brand')
|
|
|
|
act(() => {
|
|
result.current.switchRole('agency')
|
|
})
|
|
|
|
expect(result.current.user?.role).toBe('agency')
|
|
// Check localStorage was updated with new role
|
|
const storedCalls = storage.setItem.mock.calls.filter(
|
|
([key]: [string, string]) => key === 'miaosi_user'
|
|
)
|
|
const lastStored = JSON.parse(storedCalls[storedCalls.length - 1][1])
|
|
expect(lastStored.role).toBe('agency')
|
|
})
|
|
|
|
it('does nothing when no user is logged in', async () => {
|
|
const { result } = renderHook(() => useAuth(), { wrapper })
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
act(() => {
|
|
result.current.switchRole('creator')
|
|
})
|
|
|
|
expect(result.current.user).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ---- useAuth outside provider ----
|
|
|
|
describe('useAuth outside AuthProvider', () => {
|
|
it('throws an error when used outside AuthProvider', () => {
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
expect(() => {
|
|
renderHook(() => useAuth())
|
|
}).toThrow('useAuth must be used within an AuthProvider')
|
|
|
|
consoleSpy.mockRestore()
|
|
})
|
|
})
|
|
})
|