1
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
const apiClientMocks = vi.hoisted(() => ({
|
||||
emitAuthStateChange: vi.fn(),
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
getStoredAccessToken,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
|
||||
requestJson: apiClientMocks.requestJson,
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiClientError } from './apiClient';
|
||||
import {
|
||||
authEntryWithStoredCredentials,
|
||||
bindWechatPhone,
|
||||
@@ -22,49 +27,34 @@ import {
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
} from './authService';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
function createWindowMock(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return values.has(key) ? values.get(key)! : null;
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
values.delete(key);
|
||||
},
|
||||
clear() {
|
||||
values.clear();
|
||||
history: {
|
||||
replaceState: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('authService auto auth', () => {
|
||||
describe('authService', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
requestJsonMock.mockReset();
|
||||
clearStoredAccessToken();
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('window', createWindowMock());
|
||||
});
|
||||
|
||||
it('creates credentials that match current username/password constraints', () => {
|
||||
@@ -75,9 +65,8 @@ describe('authService auto auth', () => {
|
||||
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('stores jwt after auth entry without persisting guest credentials locally', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-token-value',
|
||||
it('auth entry trims guest credentials and emits auth state changes', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_1',
|
||||
username: 'guest_abc123abc123',
|
||||
@@ -95,8 +84,7 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
|
||||
expect(user.username).toBe('guest_abc123abc123');
|
||||
expect(getStoredAccessToken()).toBe('jwt-token-value');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
@@ -106,11 +94,11 @@ describe('authService auto auth', () => {
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-restored',
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_saved',
|
||||
username: 'guest_saveduser01',
|
||||
@@ -124,7 +112,7 @@ describe('authService auto auth', () => {
|
||||
|
||||
const result = await ensureAutoAuthUser();
|
||||
const authEntryBody = JSON.parse(
|
||||
requestJsonMock.mock.calls[0]?.[1]?.body as string,
|
||||
apiClientMocks.requestJson.mock.calls[0]?.[1]?.body as string,
|
||||
) as {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -136,19 +124,11 @@ describe('authService auto auth', () => {
|
||||
/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u,
|
||||
);
|
||||
expect(authEntryBody).toEqual(result.credentials);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(String),
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent auto auth requests', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-auto',
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_auto',
|
||||
username: 'guest_auto',
|
||||
@@ -165,19 +145,12 @@ describe('authService auto auth', () => {
|
||||
ensureAutoAuthUser(),
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledTimes(1);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
|
||||
expect(firstResult).toEqual(secondResult);
|
||||
const authEntryBody = JSON.parse(
|
||||
requestJsonMock.mock.calls[0]?.[1]?.body as string,
|
||||
) as {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
expect(authEntryBody).toEqual(firstResult.credentials);
|
||||
});
|
||||
|
||||
it('sends phone login code through the new auth endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
it('sends phone login code through the auth endpoint', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
@@ -187,7 +160,7 @@ describe('authService auto auth', () => {
|
||||
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
|
||||
|
||||
expect(result.cooldownSeconds).toBe(60);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
@@ -199,28 +172,6 @@ describe('authService auto auth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sends phone change code with the correct scene', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
await sendPhoneLoginCode('13900139000', 'change_phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
scene: 'change_phone',
|
||||
}),
|
||||
}),
|
||||
'发送验证码失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts captcha challenge details from api errors', () => {
|
||||
expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull();
|
||||
|
||||
@@ -246,9 +197,8 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('stores jwt after phone login', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'phone-jwt-token',
|
||||
it('emits auth state changes after phone login', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '138****8000',
|
||||
@@ -263,8 +213,7 @@ describe('authService auto auth', () => {
|
||||
const user = await loginWithPhoneCode('13800138000', '123456');
|
||||
|
||||
expect(user.username).toBe('138****8000');
|
||||
expect(getStoredAccessToken()).toBe('phone-jwt-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/login',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
@@ -274,11 +223,11 @@ describe('authService auto auth', () => {
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('binds wechat phone and stores jwt after activation', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'wechat-bind-token',
|
||||
it('emits auth state changes after wechat bind activation', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_wechat',
|
||||
username: '138****8000',
|
||||
@@ -293,22 +242,11 @@ describe('authService auto auth', () => {
|
||||
const user = await bindWechatPhone('13800138000', '123456');
|
||||
|
||||
expect(user.wechatBound).toBe(true);
|
||||
expect(getStoredAccessToken()).toBe('wechat-bind-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'绑定手机号失败',
|
||||
);
|
||||
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('changes phone number without replacing the stored access token', async () => {
|
||||
setStoredAccessToken('active-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
it('changes phone number without emitting a global auth state refresh', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '139****9000',
|
||||
@@ -323,41 +261,29 @@ describe('authService auto auth', () => {
|
||||
const user = await changePhoneNumber('13900139000', '123456');
|
||||
|
||||
expect(user.phoneNumberMasked).toBe('139****9000');
|
||||
expect(getStoredAccessToken()).toBe('active-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/change',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'更换手机号失败',
|
||||
);
|
||||
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts wechat login by navigating to backend authorization url', async () => {
|
||||
const assignMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: assignMock,
|
||||
},
|
||||
history: {
|
||||
replaceState: vi.fn(),
|
||||
},
|
||||
});
|
||||
requestJsonMock.mockResolvedValue({
|
||||
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(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/start?redirectPath=%2F',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
@@ -370,14 +296,14 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
|
||||
it('loads available login methods for the unauthenticated login screen', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'wechat'],
|
||||
});
|
||||
|
||||
const result = await getAuthLoginOptions();
|
||||
|
||||
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/login-options',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
@@ -386,20 +312,22 @@ describe('authService auto auth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes auth callback hash and stores token', () => {
|
||||
it('consumes auth callback hash without trying to persist tokens locally', () => {
|
||||
const replaceStateMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone',
|
||||
},
|
||||
history: {
|
||||
replaceState: replaceStateMock,
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '#auth_provider=wechat&auth_binding_status=pending_bind_phone',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
history: {
|
||||
replaceState: replaceStateMock,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = consumeAuthCallbackResult();
|
||||
|
||||
@@ -408,12 +336,36 @@ describe('authService auto auth', () => {
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
error: null,
|
||||
});
|
||||
expect(getStoredAccessToken()).toBe('wx-token');
|
||||
expect(apiClientMocks.emitAuthStateChange).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 () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'usess_1',
|
||||
@@ -432,17 +384,10 @@ describe('authService auto auth', () => {
|
||||
const sessions = await getAuthSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads recent auth audit logs', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
logs: [
|
||||
{
|
||||
id: 'audit_1',
|
||||
@@ -459,17 +404,10 @@ describe('authService auto auth', () => {
|
||||
const logs = await getAuthAuditLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/audit-logs',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取账号操作记录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads current risk blocks', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
blocks: [
|
||||
{
|
||||
scopeType: 'phone',
|
||||
@@ -484,23 +422,16 @@ describe('authService auto auth', () => {
|
||||
const blocks = await getAuthRiskBlocks();
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取安全状态失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('lifts a risk block by scope type', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await liftAuthRiskBlock('phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks/phone/lift',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
@@ -509,37 +440,20 @@ describe('authService auto auth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes a remote auth session by id', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await revokeAuthSession('usess_123');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions/usess_123/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears local auth state after logout all sessions', async () => {
|
||||
setStoredAccessToken('stale-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
it('emits auth change after logout all sessions', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await logoutAllAuthSessions();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/logout-all',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'退出全部设备失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user