import { beforeEach, describe, expect, it, vi } from 'vitest'; const apiClientMocks = vi.hoisted(() => ({ emitAuthStateChange: vi.fn(), requestJson: vi.fn(), })); vi.mock('./apiClient', async () => { const actual = await vi.importActual('./apiClient'); return { ...actual, emitAuthStateChange: apiClientMocks.emitAuthStateChange, requestJson: apiClientMocks.requestJson, }; }); import { ApiClientError } from './apiClient'; import { clearStoredAccessToken, getStoredAccessToken } from './apiClient'; import { authEntryWithStoredCredentials, bindWechatPhone, changePhoneNumber, consumeAuthCallbackResult, createAutoAuthCredentials, ensureAutoAuthUser, getAuthAuditLogs, getAuthLoginOptions, getAuthRiskBlocks, getAuthSessions, getCaptchaChallengeFromError, getCurrentAuthUser, liftAuthRiskBlock, loginWithPhoneCode, logoutAllAuthSessions, sendPhoneLoginCode, startWechatLogin, } from './authService'; function createLocalStorageMock() { const store = new Map(); return { getItem(key: string) { return store.has(key) ? store.get(key)! : null; }, setItem(key: string, value: string) { store.set(key, String(value)); }, removeItem(key: string) { store.delete(key); }, clear() { store.clear(); }, }; } function createWindowMock(overrides: Record = {}) { return { dispatchEvent: vi.fn(), localStorage: createLocalStorageMock(), location: { pathname: '/', hash: '', search: '', assign: vi.fn(), }, history: { replaceState: vi.fn(), }, ...overrides, }; } describe('authService', () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal('window', createWindowMock()); clearStoredAccessToken({ emit: false }); }); it('creates credentials that match current username/password constraints', () => { const credentials = createAutoAuthCredentials(); expect(credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u); expect(credentials.password).toMatch(/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u); expect(credentials.password.length).toBeGreaterThanOrEqual(6); }); it('auth entry trims guest credentials and写入 access token', async () => { apiClientMocks.requestJson.mockResolvedValue({ token: 'jwt-entry-token', user: { id: 'user_1', username: 'guest_abc123abc123', displayName: 'guest_abc123abc123', phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, }, }); const user = await authEntryWithStoredCredentials({ username: ' guest_abc123abc123 ', password: ' auto_secret_password ', }); expect(user.username).toBe('guest_abc123abc123'); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/entry', expect.objectContaining({ body: JSON.stringify({ username: 'guest_abc123abc123', password: 'auto_secret_password', }), }), '登录失败', ); expect(getStoredAccessToken()).toBe('jwt-entry-token'); expect(window.dispatchEvent).not.toHaveBeenCalled(); }); it('creates a fresh guest credential pair for auto auth when a session is missing', async () => { apiClientMocks.requestJson.mockResolvedValue({ token: 'jwt-auto-token', user: { id: 'user_saved', username: 'guest_saveduser01', displayName: 'guest_saveduser01', phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, }, }); const result = await ensureAutoAuthUser(); const authEntryBody = JSON.parse( apiClientMocks.requestJson.mock.calls[0]?.[1]?.body as string, ) as { username: string; password: string; }; expect(result.user.username).toBe('guest_saveduser01'); expect(result.credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u); expect(result.credentials.password).toMatch( /^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u, ); expect(authEntryBody).toEqual(result.credentials); expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1); }); it('deduplicates concurrent auto auth requests', async () => { apiClientMocks.requestJson.mockResolvedValue({ token: 'jwt-auto-shared-token', user: { id: 'user_auto', username: 'guest_auto', displayName: 'guest_auto', phoneNumberMasked: null, loginMethod: 'password', bindingStatus: 'active', wechatBound: false, }, }); const [firstResult, secondResult] = await Promise.all([ ensureAutoAuthUser(), ensureAutoAuthUser(), ]); expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1); expect(firstResult).toEqual(secondResult); }); it('sends phone login code through the auth endpoint', async () => { apiClientMocks.requestJson.mockResolvedValue({ ok: true, cooldownSeconds: 60, expiresInSeconds: 300, providerRequestId: 'mock-request-id', }); const result = await sendPhoneLoginCode(' 138 0013 8000 '); expect(result.cooldownSeconds).toBe(60); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/phone/send-code', expect.objectContaining({ body: JSON.stringify({ phone: '13800138000', scene: 'login', }), }), '发送验证码失败', ); }); it('extracts captcha challenge details from api errors', () => { expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull(); const captchaError = new ApiClientError({ message: '需要完成人机校验', status: 403, code: 'CAPTCHA_REQUIRED', details: { captchaChallenge: { challengeId: 'captcha_1', promptText: '请输入图中的验证码后再获取短信验证码', imageDataUrl: 'data:image/svg+xml;base64,abc', expiresInSeconds: 180, }, }, }); expect(getCaptchaChallengeFromError(captchaError)).toEqual({ challengeId: 'captcha_1', promptText: '请输入图中的验证码后再获取短信验证码', imageDataUrl: 'data:image/svg+xml;base64,abc', expiresInSeconds: 180, }); }); it('stores renewed access token after phone login', async () => { apiClientMocks.requestJson.mockResolvedValue({ token: 'jwt-phone-token', user: { id: 'user_phone', username: '138****8000', displayName: '138****8000', phoneNumberMasked: '138****8000', loginMethod: 'phone', bindingStatus: 'active', wechatBound: false, }, }); const user = await loginWithPhoneCode('13800138000', '123456'); expect(user.username).toBe('138****8000'); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/phone/login', expect.objectContaining({ body: JSON.stringify({ phone: '13800138000', code: '123456', }), }), '登录失败', ); expect(getStoredAccessToken()).toBe('jwt-phone-token'); expect(window.dispatchEvent).not.toHaveBeenCalled(); }); it('stores renewed access token after wechat bind activation', async () => { apiClientMocks.requestJson.mockResolvedValue({ token: 'jwt-wechat-bind-token', user: { id: 'user_wechat', username: '138****8000', displayName: '138****8000', phoneNumberMasked: '138****8000', loginMethod: 'wechat', bindingStatus: 'active', wechatBound: true, }, }); const user = await bindWechatPhone('13800138000', '123456'); expect(user.wechatBound).toBe(true); expect(getStoredAccessToken()).toBe('jwt-wechat-bind-token'); expect(window.dispatchEvent).not.toHaveBeenCalled(); }); it('changes phone number without emitting a global auth state refresh', async () => { apiClientMocks.requestJson.mockResolvedValue({ user: { id: 'user_phone', username: '139****9000', displayName: '139****9000', phoneNumberMasked: '139****9000', loginMethod: 'phone', bindingStatus: 'active', wechatBound: false, }, }); const user = await changePhoneNumber('13900139000', '123456'); expect(user.phoneNumberMasked).toBe('139****9000'); expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled(); }); it('starts wechat login by navigating to backend authorization url', async () => { const assignMock = vi.fn(); vi.stubGlobal( 'window', createWindowMock({ location: { pathname: '/', hash: '', search: '', assign: assignMock, }, }), ); apiClientMocks.requestJson.mockResolvedValue({ authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123', }); await startWechatLogin(); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/wechat/start?redirectPath=%2F', expect.objectContaining({ method: 'GET', }), '微信登录暂不可用', ); expect(assignMock).toHaveBeenCalledWith( '/api/auth/wechat/callback?mock_code=wx-user&state=state123', ); }); it('loads available login methods for the unauthenticated login screen', async () => { apiClientMocks.requestJson.mockResolvedValue({ availableLoginMethods: ['phone', 'wechat'], }); const result = await getAuthLoginOptions(); expect(result.availableLoginMethods).toEqual(['phone', 'wechat']); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/login-options', expect.objectContaining({ method: 'GET', }), '读取登录方式失败', ); }); it('consumes auth callback hash and persists the returned access token', () => { const replaceStateMock = vi.fn(); vi.stubGlobal( 'window', createWindowMock({ location: { pathname: '/', search: '', hash: '#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone', assign: vi.fn(), }, history: { replaceState: replaceStateMock, }, }), ); const result = consumeAuthCallbackResult(); expect(result).toEqual({ provider: 'wechat', bindingStatus: 'pending_bind_phone', error: null, }); expect(getStoredAccessToken()).toBe('jwt-callback-token'); expect(window.dispatchEvent).not.toHaveBeenCalled(); expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/'); }); it('gets current auth user with silent auth-state notification settings', async () => { apiClientMocks.requestJson.mockResolvedValue({ user: null, availableLoginMethods: ['phone'], }); const result = await getCurrentAuthUser(); expect(result).toEqual({ user: null, availableLoginMethods: ['phone'], }); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/me', expect.objectContaining({ method: 'GET', }), '读取当前用户失败', { notifyAuthStateChange: false, }, ); }); it('loads auth sessions from account center endpoint', async () => { apiClientMocks.requestJson.mockResolvedValue({ sessions: [ { sessionId: 'usess_1', clientType: 'browser', clientLabel: '网页端浏览器', userAgent: 'Mozilla/5.0', ipMasked: '127.0.*.*', isCurrent: true, createdAt: '2026-04-09T10:00:00.000Z', lastSeenAt: '2026-04-09T10:30:00.000Z', expiresAt: '2026-05-09T10:30:00.000Z', }, ], }); const sessions = await getAuthSessions(); expect(sessions).toHaveLength(1); }); it('loads recent auth audit logs', async () => { apiClientMocks.requestJson.mockResolvedValue({ logs: [ { id: 'audit_1', eventType: 'phone_login', title: '手机号登录', detail: '使用手机号 138****8000 完成登录', ipMasked: '127.0.*.*', userAgent: 'Mozilla/5.0', createdAt: '2026-04-09T10:30:00.000Z', }, ], }); const logs = await getAuthAuditLogs(); expect(logs).toHaveLength(1); }); it('loads current risk blocks', async () => { apiClientMocks.requestJson.mockResolvedValue({ blocks: [ { scopeType: 'phone', title: '手机号保护中', detail: '该手机号因异常尝试已被临时保护,请约 30 分钟后再试', expiresAt: '2026-04-09T11:00:00.000Z', remainingSeconds: 1800, }, ], }); const blocks = await getAuthRiskBlocks(); expect(blocks).toHaveLength(1); }); it('lifts a risk block by scope type', async () => { apiClientMocks.requestJson.mockResolvedValue({ ok: true, }); await liftAuthRiskBlock('phone'); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/risk-blocks/phone/lift', expect.objectContaining({ method: 'POST', }), '解除保护失败', ); }); it('emits auth change after logout all sessions', async () => { apiClientMocks.requestJson.mockResolvedValue({ ok: true, }); await logoutAllAuthSessions(); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/logout-all', expect.objectContaining({ method: 'POST', }), '退出全部设备失败', ); expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1); }); });