44748b7846
来自今早群友反映 before:  and failed after:    * https://developers.weixin.qq.com/miniprogram/dev/server/API/user-info/phone-number/api_getphonenumber.html#Res-phone-info-Object-Payload 参考微信这个文档修改了微信返回的struct 手机号 区域码字段不应是Optional * 登录注册改密码改绑定等 api 把单一 phone字段改成 country_code(可选,缺省为86) + pure_phone_number 分别进行了前后端校验 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/104 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
896 lines
25 KiB
TypeScript
896 lines
25 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
const apiClientMocks = vi.hoisted(() => ({
|
||
emitAuthStateChange: vi.fn(),
|
||
requestJson: vi.fn(),
|
||
}));
|
||
|
||
const hostBridgeMocks = vi.hoisted(() => ({
|
||
openHostExternalUrl: vi.fn(async () => false),
|
||
}));
|
||
|
||
vi.mock('./apiClient', async () => {
|
||
const actual =
|
||
await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||
return {
|
||
...actual,
|
||
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
|
||
requestJson: apiClientMocks.requestJson,
|
||
};
|
||
});
|
||
|
||
vi.mock('./host-bridge/hostBridge', async () => {
|
||
const actual = await vi.importActual<
|
||
typeof import('./host-bridge/hostBridge')
|
||
>('./host-bridge/hostBridge');
|
||
return {
|
||
...actual,
|
||
openHostExternalUrl: hostBridgeMocks.openHostExternalUrl,
|
||
};
|
||
});
|
||
|
||
import { ApiClientError } from './apiClient';
|
||
import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
|
||
import {
|
||
authEntry,
|
||
bindWechatPhone,
|
||
changePassword,
|
||
changePhoneNumber,
|
||
consumeAuthCallbackResult,
|
||
getAuthAuditLogs,
|
||
getAuthLoginOptions,
|
||
getAuthRiskBlocks,
|
||
getAuthSessions,
|
||
getCaptchaChallengeFromError,
|
||
getCurrentAuthUser,
|
||
getPublicAuthUserById,
|
||
isWechatMiniProgramWebViewRuntime,
|
||
liftAuthRiskBlock,
|
||
loginWithPhoneCode,
|
||
logoutAllAuthSessions,
|
||
normalizePhoneInput,
|
||
redeemRegistrationInviteCode,
|
||
requestWechatMiniProgramPhoneLogin,
|
||
revokeAuthSession,
|
||
revokeAuthSessions,
|
||
sendPhoneLoginCode,
|
||
startWechatBind,
|
||
startWechatLogin,
|
||
updateAuthProfile,
|
||
validateAndNormalizeMainlandChinaPhoneInput,
|
||
} from './authService';
|
||
|
||
function createLocalStorageMock() {
|
||
const store = new Map<string, string>();
|
||
|
||
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<string, unknown> = {}) {
|
||
return {
|
||
dispatchEvent: vi.fn(),
|
||
localStorage: createLocalStorageMock(),
|
||
location: {
|
||
pathname: '/',
|
||
hash: '',
|
||
search: '',
|
||
assign: vi.fn(),
|
||
},
|
||
history: {
|
||
replaceState: vi.fn(),
|
||
},
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('authService', () => {
|
||
beforeEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.clearAllMocks();
|
||
hostBridgeMocks.openHostExternalUrl.mockResolvedValue(false);
|
||
vi.stubGlobal('window', createWindowMock());
|
||
clearStoredAccessToken({ emit: false });
|
||
});
|
||
|
||
it('normalizes mainland China browser autofill phone numbers to national format', () => {
|
||
expect(normalizePhoneInput('+86 198 7654 3210')).toBe('19876543210');
|
||
expect(normalizePhoneInput('86-198-7654-3210')).toBe('19876543210');
|
||
expect(normalizePhoneInput('198 7654 3210')).toBe('19876543210');
|
||
});
|
||
|
||
it('validates mainland China phone numbers before calling auth APIs', async () => {
|
||
expect(
|
||
validateAndNormalizeMainlandChinaPhoneInput('+86 198 7654 3210'),
|
||
).toBe('19876543210');
|
||
expect(validateAndNormalizeMainlandChinaPhoneInput('198 7654 3210')).toBe(
|
||
'19876543210',
|
||
);
|
||
|
||
await expect(sendPhoneLoginCode('+1 202 555 0123')).rejects.toThrow(
|
||
'仅支持中国大陆手机号(+86)',
|
||
);
|
||
expect(apiClientMocks.requestJson).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('auth entry posts phone password credentials and 写入 access token', async () => {
|
||
apiClientMocks.requestJson.mockResolvedValue({
|
||
token: 'jwt-entry-token',
|
||
user: {
|
||
id: 'user_1',
|
||
publicUserCode: 'SY-00000001',
|
||
displayName: '138****8000',
|
||
avatarUrl: null,
|
||
phoneNumberMasked: '138****8000',
|
||
loginMethod: 'password',
|
||
bindingStatus: 'active',
|
||
wechatBound: false,
|
||
},
|
||
});
|
||
|
||
const user = await authEntry(' 138 0013 8000 ', ' secret123 ');
|
||
|
||
expect(user.phoneNumberMasked).toBe('138****8000');
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/auth/entry',
|
||
expect.objectContaining({
|
||
body: JSON.stringify({
|
||
countryCode: '86',
|
||
purePhoneNumber: '13800138000',
|
||
password: 'secret123',
|
||
}),
|
||
}),
|
||
'登录失败',
|
||
{
|
||
skipAuth: true,
|
||
skipRefresh: true,
|
||
},
|
||
);
|
||
expect(getStoredAccessToken()).toBe('jwt-entry-token');
|
||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('update profile trims nickname and posts avatar data url', async () => {
|
||
apiClientMocks.requestJson.mockResolvedValue({
|
||
user: {
|
||
id: 'user_1',
|
||
publicUserCode: 'SY-00000001',
|
||
displayName: '旅人甲',
|
||
avatarUrl: 'data:image/png;base64,AAAA',
|
||
phoneNumberMasked: '138****8000',
|
||
loginMethod: 'password',
|
||
bindingStatus: 'active',
|
||
wechatBound: false,
|
||
},
|
||
});
|
||
|
||
const user = await updateAuthProfile({
|
||
displayName: ' 旅人甲 ',
|
||
avatarDataUrl: ' data:image/png;base64,AAAA ',
|
||
});
|
||
|
||
expect(user.avatarUrl).toBe('data:image/png;base64,AAAA');
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/profile/me',
|
||
expect.objectContaining({
|
||
method: 'PATCH',
|
||
body: JSON.stringify({
|
||
displayName: '旅人甲',
|
||
avatarDataUrl: 'data:image/png;base64,AAAA',
|
||
}),
|
||
}),
|
||
'更新资料失败',
|
||
);
|
||
});
|
||
|
||
it('change password clears local auth session after backend success', async () => {
|
||
window.localStorage.setItem(
|
||
'genarrative:access-token',
|
||
'jwt-before-password-change',
|
||
);
|
||
apiClientMocks.requestJson.mockResolvedValue({
|
||
user: {
|
||
id: 'user_1',
|
||
publicUserCode: 'SY-00000001',
|
||
displayName: '旅人甲',
|
||
avatarUrl: null,
|
||
phoneNumberMasked: '138****8000',
|
||
loginMethod: 'password',
|
||
bindingStatus: 'active',
|
||
wechatBound: false,
|
||
},
|
||
});
|
||
|
||
const user = await changePassword(' old-password ', ' new-password ');
|
||
|
||
expect(user.id).toBe('user_1');
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/auth/password/change',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
currentPassword: 'old-password',
|
||
newPassword: 'new-password',
|
||
}),
|
||
}),
|
||
'修改密码失败',
|
||
);
|
||
expect(getStoredAccessToken()).toBe('');
|
||
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
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('+86 138 0013 8000');
|
||
|
||
expect(result.cooldownSeconds).toBe(60);
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/auth/phone/send-code',
|
||
expect.objectContaining({
|
||
body: JSON.stringify({
|
||
countryCode: '86',
|
||
purePhoneNumber: '13800138000',
|
||
scene: 'login',
|
||
}),
|
||
}),
|
||
'发送验证码失败',
|
||
{
|
||
skipAuth: true,
|
||
skipRefresh: true,
|
||
},
|
||
);
|
||
});
|
||
|
||
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',
|
||
publicUserCode: 'SY-00000004',
|
||
displayName: '138****8000',
|
||
avatarUrl: null,
|
||
phoneNumberMasked: '138****8000',
|
||
loginMethod: 'phone',
|
||
bindingStatus: 'active',
|
||
wechatBound: false,
|
||
},
|
||
});
|
||
|
||
const response = await loginWithPhoneCode(
|
||
'+86 138 0013 8000',
|
||
'123456',
|
||
'spring-2026',
|
||
);
|
||
|
||
expect(response.user.displayName).toBe('138****8000');
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/auth/phone/login',
|
||
expect.objectContaining({
|
||
body: JSON.stringify({
|
||
countryCode: '86',
|
||
purePhoneNumber: '13800138000',
|
||
code: '123456',
|
||
inviteCode: 'SPRING2026',
|
||
}),
|
||
}),
|
||
'登录失败',
|
||
{
|
||
skipAuth: true,
|
||
skipRefresh: true,
|
||
},
|
||
);
|
||
expect(getStoredAccessToken()).toBe('jwt-phone-token');
|
||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('redeems registration invite code after authenticated new account login', async () => {
|
||
apiClientMocks.requestJson.mockResolvedValue({
|
||
center: {
|
||
inviteCode: 'SY12345678',
|
||
inviteLinkPath: '/?inviteCode=SY12345678',
|
||
invitedCount: 1,
|
||
rewardedInviteCount: 1,
|
||
todayInviterRewardCount: 0,
|
||
todayInviterRewardRemaining: 3,
|
||
rewardPoints: 30,
|
||
hasRedeemedCode: true,
|
||
boundInviterUserId: 'user_inviter',
|
||
boundAt: '2026-05-01T00:00:00Z',
|
||
updatedAt: '2026-05-01T00:00:00Z',
|
||
},
|
||
inviteeRewardGranted: true,
|
||
inviterRewardGranted: true,
|
||
inviteeBalanceAfter: 30,
|
||
inviterBalanceAfter: 30,
|
||
});
|
||
|
||
const response = await redeemRegistrationInviteCode(' spring-2026 ');
|
||
|
||
expect(response.inviteeRewardGranted).toBe(true);
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/profile/referrals/redeem-code',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
inviteCode: 'SPRING2026',
|
||
}),
|
||
}),
|
||
'填写邀请码失败',
|
||
);
|
||
});
|
||
|
||
it('stores renewed access token after wechat bind activation', async () => {
|
||
apiClientMocks.requestJson.mockResolvedValue({
|
||
token: 'jwt-wechat-bind-token',
|
||
user: {
|
||
id: 'user_wechat',
|
||
publicUserCode: 'SY-00000005',
|
||
displayName: '138****8000',
|
||
avatarUrl: null,
|
||
phoneNumberMasked: '138****8000',
|
||
loginMethod: 'wechat',
|
||
bindingStatus: 'active',
|
||
wechatBound: true,
|
||
},
|
||
});
|
||
|
||
const user = await bindWechatPhone('13800138000', '123456');
|
||
|
||
expect(user.wechatBound).toBe(true);
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/auth/wechat/bind-phone',
|
||
expect.objectContaining({
|
||
body: JSON.stringify({
|
||
countryCode: '86',
|
||
purePhoneNumber: '13800138000',
|
||
code: '123456',
|
||
}),
|
||
}),
|
||
'绑定手机号失败',
|
||
);
|
||
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',
|
||
publicUserCode: 'SY-00000006',
|
||
displayName: '139****9000',
|
||
avatarUrl: null,
|
||
phoneNumberMasked: '139****9000',
|
||
loginMethod: 'phone',
|
||
bindingStatus: 'active',
|
||
wechatBound: false,
|
||
},
|
||
});
|
||
|
||
const user = await changePhoneNumber('13900139000', '123456');
|
||
|
||
expect(user.phoneNumberMasked).toBe('139****9000');
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/auth/phone/change',
|
||
expect.objectContaining({
|
||
body: JSON.stringify({
|
||
countryCode: '86',
|
||
purePhoneNumber: '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',
|
||
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',
|
||
}),
|
||
'微信登录暂不可用',
|
||
{
|
||
skipAuth: true,
|
||
skipRefresh: true,
|
||
},
|
||
);
|
||
expect(hostBridgeMocks.openHostExternalUrl).toHaveBeenCalledWith({
|
||
url: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||
});
|
||
expect(assignMock).toHaveBeenCalledWith(
|
||
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||
);
|
||
});
|
||
|
||
it('starts wechat login through native host external browser when available', async () => {
|
||
const assignMock = vi.fn();
|
||
hostBridgeMocks.openHostExternalUrl.mockResolvedValue(true);
|
||
vi.stubGlobal(
|
||
'window',
|
||
createWindowMock({
|
||
location: {
|
||
pathname: '/',
|
||
hash: '',
|
||
search: '',
|
||
assign: assignMock,
|
||
},
|
||
}),
|
||
);
|
||
apiClientMocks.requestJson.mockResolvedValue({
|
||
authorizationUrl: 'https://open.weixin.qq.com/connect/oauth2/authorize',
|
||
});
|
||
|
||
await startWechatLogin();
|
||
|
||
expect(hostBridgeMocks.openHostExternalUrl).toHaveBeenCalledWith({
|
||
url: 'https://open.weixin.qq.com/connect/oauth2/authorize',
|
||
});
|
||
expect(assignMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('keeps current query when starting wechat login', async () => {
|
||
vi.stubGlobal(
|
||
'window',
|
||
createWindowMock({
|
||
location: {
|
||
pathname: '/play',
|
||
hash: '',
|
||
search: '?clientRuntime=wechat_h5&from=recharge',
|
||
assign: vi.fn(),
|
||
},
|
||
}),
|
||
);
|
||
apiClientMocks.requestJson.mockResolvedValue({
|
||
authorizationUrl: 'https://open.weixin.qq.com/connect/oauth2/authorize',
|
||
});
|
||
|
||
await startWechatLogin();
|
||
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/auth/wechat/start?redirectPath=%2Fplay%3FclientRuntime%3Dwechat_h5%26from%3Drecharge',
|
||
expect.objectContaining({
|
||
method: 'GET',
|
||
}),
|
||
'微信登录暂不可用',
|
||
{
|
||
skipAuth: true,
|
||
skipRefresh: true,
|
||
},
|
||
);
|
||
});
|
||
|
||
it('keeps current query and auth when starting wechat bind', async () => {
|
||
vi.stubGlobal(
|
||
'window',
|
||
createWindowMock({
|
||
location: {
|
||
pathname: '/play',
|
||
hash: '',
|
||
search: '?clientRuntime=wechat_h5&from=recharge',
|
||
assign: vi.fn(),
|
||
},
|
||
}),
|
||
);
|
||
apiClientMocks.requestJson.mockResolvedValue({
|
||
authorizationUrl: 'https://open.weixin.qq.com/connect/oauth2/authorize',
|
||
});
|
||
|
||
await startWechatBind();
|
||
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/auth/wechat/bind-start?redirectPath=%2Fplay%3FclientRuntime%3Dwechat_h5%26from%3Drecharge',
|
||
expect.objectContaining({
|
||
method: 'GET',
|
||
}),
|
||
'微信授权暂不可用',
|
||
);
|
||
});
|
||
|
||
it('requests mini program phone login by opening the native auth page', async () => {
|
||
const navigateTo = vi.fn(
|
||
(options: { url: string; success?: () => void }) => {
|
||
options.success?.();
|
||
},
|
||
);
|
||
vi.stubGlobal(
|
||
'window',
|
||
createWindowMock({
|
||
location: {
|
||
pathname: '/',
|
||
hash: '',
|
||
search: '?clientRuntime=wechat_mini_program',
|
||
assign: vi.fn(),
|
||
},
|
||
wx: {
|
||
miniProgram: {
|
||
navigateTo,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
const result = await requestWechatMiniProgramPhoneLogin();
|
||
|
||
expect(result).toBe(true);
|
||
expect(navigateTo).toHaveBeenCalledWith({
|
||
url: '/pages/web-view/index?authAction=login&returnTo=previous',
|
||
success: expect.any(Function),
|
||
fail: expect.any(Function),
|
||
});
|
||
});
|
||
|
||
it('detects mini program user agent before the WeChat bridge is ready', () => {
|
||
vi.stubGlobal('navigator', {
|
||
userAgent:
|
||
'Mozilla/5.0 iPhone MicroMessenger/8.0.49 NetType/WIFI Language/zh_CN miniProgram',
|
||
});
|
||
vi.stubGlobal(
|
||
'window',
|
||
createWindowMock({
|
||
location: {
|
||
pathname: '/',
|
||
hash: '',
|
||
search: '',
|
||
assign: vi.fn(),
|
||
},
|
||
}),
|
||
);
|
||
|
||
expect(isWechatMiniProgramWebViewRuntime()).toBe(true);
|
||
});
|
||
|
||
it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => {
|
||
const navigateTo = vi.fn(
|
||
(options: { url: string; success?: () => void }) => {
|
||
options.success?.();
|
||
},
|
||
);
|
||
const scriptListeners = new Map<string, EventListener>();
|
||
const existingScript = {
|
||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||
scriptListeners.set(type, listener);
|
||
}),
|
||
};
|
||
vi.stubGlobal(
|
||
'window',
|
||
createWindowMock({
|
||
location: {
|
||
pathname: '/',
|
||
hash: '',
|
||
search: '?clientRuntime=wechat_mini_program',
|
||
assign: vi.fn(),
|
||
},
|
||
}),
|
||
);
|
||
vi.stubGlobal('document', {
|
||
querySelector: vi.fn(() => existingScript),
|
||
head: {
|
||
appendChild: vi.fn(),
|
||
},
|
||
createElement: vi.fn(),
|
||
});
|
||
|
||
const request = requestWechatMiniProgramPhoneLogin();
|
||
window.wx = {
|
||
miniProgram: {
|
||
navigateTo,
|
||
},
|
||
};
|
||
scriptListeners.get('load')?.(new Event('load'));
|
||
|
||
await expect(request).resolves.toBe(true);
|
||
expect(navigateTo).toHaveBeenCalledWith({
|
||
url: '/pages/web-view/index?authAction=login&returnTo=previous',
|
||
success: expect.any(Function),
|
||
fail: expect.any(Function),
|
||
});
|
||
});
|
||
|
||
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',
|
||
}),
|
||
'读取登录方式失败',
|
||
{
|
||
skipAuth: true,
|
||
skipRefresh: true,
|
||
},
|
||
);
|
||
});
|
||
|
||
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 public user summary by internal user id', async () => {
|
||
apiClientMocks.requestJson.mockResolvedValue({
|
||
user: {
|
||
id: 'user_00000001',
|
||
publicUserCode: 'SY-00000001',
|
||
displayName: '旅人一号',
|
||
},
|
||
});
|
||
|
||
const user = await getPublicAuthUserById(' user_00000001 ');
|
||
|
||
expect(user).toEqual({
|
||
id: 'user_00000001',
|
||
publicUserCode: 'SY-00000001',
|
||
displayName: '旅人一号',
|
||
});
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/auth/public-users/by-id/user_00000001',
|
||
expect.objectContaining({
|
||
method: 'GET',
|
||
}),
|
||
'读取用户信息失败',
|
||
{
|
||
skipAuth: true,
|
||
skipRefresh: true,
|
||
},
|
||
);
|
||
});
|
||
|
||
it('loads auth sessions from account center endpoint', async () => {
|
||
apiClientMocks.requestJson.mockResolvedValue({
|
||
sessions: [
|
||
{
|
||
sessionId: 'usess_1',
|
||
sessionIds: ['usess_1', 'usess_2'],
|
||
sessionCount: 2,
|
||
clientType: 'browser',
|
||
clientRuntime: 'chrome',
|
||
clientPlatform: 'windows',
|
||
clientLabel: '网页端浏览器',
|
||
deviceDisplayName: 'Windows / Chrome',
|
||
miniProgramAppId: null,
|
||
miniProgramEnv: null,
|
||
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);
|
||
const [session] = sessions;
|
||
expect(session?.sessionIds).toEqual(['usess_1', 'usess_2']);
|
||
expect(session?.sessionCount).toBe(2);
|
||
});
|
||
|
||
it('revokes a single auth session by backend route', async () => {
|
||
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||
|
||
await revokeAuthSession('usess_1');
|
||
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||
'/api/auth/sessions/usess_1/revoke',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
}),
|
||
'移除登录设备失败',
|
||
);
|
||
});
|
||
|
||
it('revokes grouped auth sessions once per unique session id', async () => {
|
||
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||
|
||
await revokeAuthSessions([' usess_1 ', 'usess_2', 'usess_1', '']);
|
||
|
||
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(2);
|
||
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
|
||
1,
|
||
'/api/auth/sessions/usess_1/revoke',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
}),
|
||
'移除登录设备失败',
|
||
);
|
||
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
|
||
2,
|
||
'/api/auth/sessions/usess_2/revoke',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
}),
|
||
'移除登录设备失败',
|
||
);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|