Files
Genarrative/src/components/auth/AuthGate.test.tsx
T
kdletters 56a9075582 修复推荐页封面遮罩与登录态刷新
推荐页运行态封面增加加载条并隔离层级,避免 runtime 内容穿透封面

登录态从未登录到已登录或退出后刷新当前页面,退出等待 token 清理完成后再刷新

补充推荐页封面、认证刷新与样式回归测试

同步平台链路、项目基线和 Hermes 决策文档
2026-06-07 17:59:11 +08:00

963 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
import { AuthGate, setAuthGateReloadForTest } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(),
getStoredAccessToken: vi.fn(),
refreshStoredAccessToken: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
loginWithPhoneCode: vi.fn(),
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
redeemRegistrationInviteCode: vi.fn(),
resetPassword: vi.fn(),
getAuthAuditLogs: vi.fn(),
getAuthRiskBlocks: vi.fn(),
getAuthSessions: vi.fn(),
isWechatMiniProgramWebViewRuntime: vi.fn(() => false),
requestWechatMiniProgramPhoneLogin: vi.fn(),
revokeAuthSessions: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
consumeAuthCallbackResult: vi.fn(),
}));
vi.mock('../../services/apiClient', () => ({
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
getStoredAccessToken: authMocks.getStoredAccessToken,
refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
}));
vi.mock('../../services/authService', () => ({
authEntry: authMocks.authEntry,
bindWechatPhone: vi.fn(),
changePassword: authMocks.changePassword,
changePhoneNumber: vi.fn(),
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
getStoredLastLoginPhone: vi.fn(() => ''),
getAuthAuditLogs: authMocks.getAuthAuditLogs,
getAuthLoginOptions: authMocks.getAuthLoginOptions,
getAuthRiskBlocks: authMocks.getAuthRiskBlocks,
getCurrentAuthUser: authMocks.getCurrentAuthUser,
getAuthSessions: authMocks.getAuthSessions,
getCaptchaChallengeFromError: vi.fn(() => null),
isWechatMiniProgramWebViewRuntime: authMocks.isWechatMiniProgramWebViewRuntime,
liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
logoutAuthUser: authMocks.logoutAuthUser,
requestWechatMiniProgramPhoneLogin: authMocks.requestWechatMiniProgramPhoneLogin,
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
resetPassword: authMocks.resetPassword,
revokeAuthSessions: authMocks.revokeAuthSessions,
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
setStoredLastLoginPhone: vi.fn(),
startWechatLogin: authMocks.startWechatLogin,
}));
vi.mock('../../hooks/useGameSettings', () => ({
useGameSettings: () => ({
musicVolume: 0.42,
setMusicVolume: () => {},
platformTheme: 'light',
setPlatformTheme: () => {},
hasHydratedSettings: true,
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}),
}));
vi.mock('./AccountModal', async () => {
const actual =
await vi.importActual<typeof import('./AccountModal')>('./AccountModal');
return actual;
});
vi.mock('./BindPhoneScreen', () => ({
BindPhoneScreen: () => <div>绑定手机号</div>,
}));
const mockUser: AuthUser = {
id: 'user-1',
displayName: '测试玩家',
avatarUrl: null,
publicUserCode: 'user-tester',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: false,
};
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
window.history.replaceState(null, '', '/');
setAuthGateReloadForTest(vi.fn());
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getStoredAccessToken.mockReturnValue('');
authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
authMocks.getCurrentAuthUser.mockResolvedValue({
user: null,
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValue({
token: 'jwt-phone',
user: mockUser,
created: false,
referral: null,
});
authMocks.authEntry.mockResolvedValue(mockUser);
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
authMocks.logoutAuthUser.mockResolvedValue(undefined);
authMocks.getAuthAuditLogs.mockResolvedValue([]);
authMocks.getAuthRiskBlocks.mockResolvedValue([]);
authMocks.getAuthSessions.mockResolvedValue([]);
authMocks.revokeAuthSessions.mockResolvedValue(undefined);
authMocks.redeemRegistrationInviteCode.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,
});
authMocks.resetPassword.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
expiresInSeconds: 300,
});
authMocks.startWechatLogin.mockResolvedValue(undefined);
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(false);
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
});
afterEach(() => {
setAuthGateReloadForTest(null);
});
async function acceptLegalConsent(
user: ReturnType<typeof userEvent.setup>,
dialog: HTMLElement,
) {
await user.click(
within(dialog).getByRole('switch', { name: '同意法律协议' }),
);
}
function ProtectedActionButton({
onAuthenticated,
}: {
onAuthenticated: () => void;
}) {
const authUi = useAuthUi();
return (
<button
type="button"
onClick={() => {
authUi?.requireAuth(onAuthenticated);
}}
>
进入作品
</button>
);
}
function PlatformTabStateProbe() {
const [tab, setTab] = useState<'home' | 'create'>('home');
return (
<div>
<div>当前Tab{tab === 'home' ? '首页' : '创作'}</div>
<button type="button" onClick={() => setTab('create')}>
创作
</button>
</div>
);
}
function LogoutStateProbe() {
const authUi = useAuthUi();
return (
<div>
<div>当前用户:{authUi?.user?.displayName ?? '未登录'}</div>
<div>
私有数据:{authUi?.canAccessProtectedData ? '可读取' : '不可读取'}
</div>
<button
type="button"
onClick={() => {
void authUi?.logout();
}}
>
退出登录
</button>
</div>
);
}
function AccountPanelProbe() {
const authUi = useAuthUi();
return (
<button
type="button"
onClick={() => {
authUi?.openAccountModal();
}}
>
打开账号面板
</button>
);
}
test('auth gate keeps platform content visible when phone login is available', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<div>应用内容</div>
</AuthGate>,
);
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(screen.queryByRole('button', { name: '登录' })).toBeNull();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
});
test('auth gate waits for refresh cookie rotation before exposing restored user content', async () => {
let resolveToken!: (token: string) => void;
const tokenPromise = new Promise<string>((resolve) => {
resolveToken = resolve;
});
authMocks.refreshStoredAccessToken.mockReturnValue(tokenPromise);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<div>应用内容</div>
</AuthGate>,
);
expect(screen.getByText('正在校验登录状态...')).toBeTruthy();
expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled();
resolveToken('jwt-refreshed-token');
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1);
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({
clearOnFailure: true,
});
expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled();
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
});
test('auth gate keeps a valid local token login when refresh rotation fails after reload', async () => {
authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token');
authMocks.refreshStoredAccessToken.mockRejectedValue(
new Error('refresh cookie 失效'),
);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<LogoutStateProbe />
</AuthGate>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({
clearOnFailure: false,
});
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
});
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: [],
});
render(
<AuthGate>
<div>应用内容</div>
</AuthGate>,
);
expect(await screen.findByText('应用内容')).toBeTruthy();
});
test('auth gate keeps sms and password entries available when login options are empty', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
user: null,
availableLoginMethods: [],
});
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: [],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy();
expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy();
expect(within(dialog).getByLabelText('验证码')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '获取验证码' }),
).toBeTruthy();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
expect(within(dialog).queryByText('读取登录方式失败')).toBeNull();
});
test('auth gate keeps sms and password entries available when login options request fails', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockRejectedValue(
new Error('读取登录方式失败'),
);
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy();
expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy();
expect(within(dialog).getByLabelText('验证码')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '获取验证码' }),
).toBeTruthy();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
});
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
const user = userEvent.setup();
const onAuthenticated = vi.fn();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={onAuthenticated} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(dialog).toBeTruthy();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
'13800000000',
'123456',
);
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
expect(onAuthenticated).toHaveBeenCalledTimes(1);
expect(reload).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => {
const user = userEvent.setup();
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(true);
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone', 'wechat'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
await waitFor(() => {
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(1);
});
expect(authMocks.startWechatLogin).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
expect(authMocks.isWechatMiniProgramWebViewRuntime).toHaveBeenCalled();
});
test('login modal requires first-time legal consent before sms login', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
const loginButton = within(dialog).getByRole('button', { name: '登录' });
const legalSwitch = within(dialog).getByRole('switch', {
name: '同意法律协议',
});
expect((loginButton as HTMLButtonElement).disabled).toBe(true);
expect(legalSwitch.getAttribute('aria-checked')).toBe('false');
await user.click(
within(dialog).getByRole('button', { name: '《用户协议》' }),
);
expect(
await screen.findByRole('dialog', { name: '用户协议' }),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '我知道了' }));
expect(legalSwitch.getAttribute('aria-checked')).toBe('false');
await user.click(legalSwitch);
expect(legalSwitch.getAttribute('aria-checked')).toBe('true');
expect(window.localStorage.getItem(LEGAL_CONSENT_STORAGE_KEY)).toBe('true');
expect((loginButton as HTMLButtonElement).disabled).toBe(false);
});
test('login modal defaults legal consent to checked after stored confirmation', async () => {
const user = userEvent.setup();
window.localStorage.setItem(LEGAL_CONSENT_STORAGE_KEY, 'true');
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
const legalSwitch = within(dialog).getByRole('switch', {
name: '同意法律协议',
});
expect(legalSwitch.getAttribute('aria-checked')).toBe('true');
});
test('phone login result is not overwritten by an older guest hydrate', async () => {
const user = userEvent.setup();
const onAuthenticated = vi.fn();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
authMocks.getCurrentAuthUser
.mockResolvedValueOnce({
user: null,
availableLoginMethods: ['phone'],
})
.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={onAuthenticated} />
<LogoutStateProbe />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
expect(onAuthenticated).toHaveBeenCalledTimes(1);
expect(screen.getByText('当前用户:测试玩家')).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth gate hides register entry and opens invite modal for new sms account', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValueOnce({
token: 'jwt-phone-new',
user: mockUser,
created: true,
referral: null,
});
render(
<AuthGate>
<div>公开内容</div>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
expect(await screen.findByText('公开内容')).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
await user.click(screen.getByRole('button', { name: '进入作品' }));
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
expect(within(dialog).queryByRole('tab', { name: '注册' })).toBeNull();
expect(within(dialog).queryByLabelText('邀请码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
'13800000000',
'123456',
);
});
const inviteDialog = await screen.findByRole('dialog', {
name: '请填写邀请码',
});
expect(
(within(inviteDialog).getByLabelText('邀请码') as HTMLInputElement).value,
).toBe('SPRING2026');
expect(
within(inviteDialog).getByRole('button', { name: '提交' }),
).toBeTruthy();
await user.click(within(inviteDialog).getByRole('button', { name: '提交' }));
await waitFor(() => {
expect(authMocks.redeemRegistrationInviteCode).toHaveBeenCalledWith(
'SPRING2026',
);
});
});
test('registration invite modal can skip when invite code is empty', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValueOnce({
token: 'jwt-phone-new',
user: mockUser,
created: true,
referral: null,
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
const inviteDialog = await screen.findByRole('dialog', {
name: '请填写邀请码',
});
await user.click(within(inviteDialog).getByRole('button', { name: '跳过' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '请填写邀请码' })).toBeNull();
});
expect(authMocks.redeemRegistrationInviteCode).not.toHaveBeenCalled();
});
test('auth state refresh keeps mounted platform content and local tab state', async () => {
const user = userEvent.setup();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<PlatformTabStateProbe />
</AuthGate>,
);
expect(await screen.findByText('当前Tab:首页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '创作' }));
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
let resolveToken!: (token: string) => void;
const tokenPromise = new Promise<string>((resolve) => {
resolveToken = resolve;
});
authMocks.refreshStoredAccessToken.mockReturnValueOnce(tokenPromise);
act(() => {
window.dispatchEvent(new Event('genarrative-auth-state-changed'));
});
expect(screen.queryByText('正在校验登录状态...')).toBeNull();
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
await act(async () => {
resolveToken('jwt-refreshed-token');
await tokenPromise;
});
await waitFor(() => {
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2);
});
expect(screen.getByText('当前Tab:创作')).toBeTruthy();
expect(reload).not.toHaveBeenCalled();
});
test('logout withdraws user context before backend request finishes', async () => {
const user = userEvent.setup();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
let resolveLogout!: () => void;
const logoutPromise = new Promise<void>((resolve) => {
resolveLogout = resolve;
});
authMocks.logoutAuthUser.mockReturnValueOnce(logoutPromise);
render(
<AuthGate>
<LogoutStateProbe />
</AuthGate>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '退出登录' }));
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
expect(reload).not.toHaveBeenCalled();
await act(async () => {
resolveLogout();
await logoutPromise;
});
expect(reload).toHaveBeenCalledTimes(1);
});
test('auth gate shows sms send feedback in the login modal', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.click(within(dialog).getByRole('button', { name: '获取验证码' }));
await waitFor(() => {
expect(authMocks.sendPhoneLoginCode).toHaveBeenCalledWith(
'13800000000',
'login',
{
challengeId: undefined,
answer: '',
},
);
});
expect(
within(dialog).getByText('短信请求已提交,验证码有效期约 5 分钟。'),
).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
});
test('login modal resets draft state every time it is reopened', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone', 'password'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
await user.click(
within(firstDialog).getByRole('button', { name: '获取验证码' }),
);
expect(
await within(firstDialog).findByText(
'短信请求已提交,验证码有效期约 5 分钟。',
),
).toBeTruthy();
await user.type(within(firstDialog).getByLabelText('验证码'), '123456');
await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' }));
await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd');
await user.click(
within(firstDialog).getByRole('button', { name: '忘记密码' }),
);
expect(screen.getByRole('dialog', { name: '重置密码' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '关闭登录弹窗' }));
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
await user.click(screen.getByRole('button', { name: '进入作品' }));
const reopenedDialog = screen.getByRole('dialog', { name: '账号入口' });
expect(
within(reopenedDialog)
.getByRole('tab', { name: '短信登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(
(within(reopenedDialog).getByLabelText('手机号') as HTMLInputElement).value,
).toBe('');
expect(
(within(reopenedDialog).getByLabelText('验证码') as HTMLInputElement).value,
).toBe('');
expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull();
expect(
within(reopenedDialog).queryByText(
'短信请求已提交,验证码有效期约 5 分钟。',
),
).toBeNull();
expect(
within(reopenedDialog).getByRole('button', { name: '获取验证码' }),
).toBeTruthy();
});
test('auth gate separates sms and password login by tabs', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone', 'password'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(
within(dialog)
.getByRole('tab', { name: '短信登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(within(dialog).queryByLabelText('密码')).toBeNull();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(
within(dialog)
.getByRole('tab', { name: '密码登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(within(dialog).queryByLabelText('验证码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
});
});
test('auth gate revokes merged session group and refreshes sessions', async () => {
const user = userEvent.setup();
const initialSessions: AuthSessionSummary[] = [
{
sessionId: 'usess_remote',
sessionIds: ['usess_remote', 'usess_remote_rotated'],
sessionCount: 2,
clientLabel: 'Windows / Chrome',
ipMasked: '203.0.*.*',
isCurrent: false,
createdAt: '2026-05-01T10:00:00.000Z',
lastSeenAt: '2026-05-01T10:30:00.000Z',
expiresAt: '2026-06-01T10:30:00.000Z',
},
];
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
authMocks.getAuthSessions
.mockResolvedValueOnce(initialSessions)
.mockResolvedValueOnce([]);
render(
<AuthGate>
<AccountPanelProbe />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '打开账号面板' }));
const accountDialog = await screen.findByRole('dialog', {
name: '账号信息',
});
await user.click(within(accountDialog).getByRole('button', { name: '踢下线' }));
await waitFor(() => {
expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([
'usess_remote',
'usess_remote_rotated',
]);
expect(authMocks.getAuthSessions).toHaveBeenCalledTimes(2);
});
});
test('auth gate clears account state after password change', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
authMocks.getAuthSessions.mockResolvedValue([]);
authMocks.changePassword.mockResolvedValue(mockUser);
render(
<AuthGate>
<div>
<LogoutStateProbe />
<AccountPanelProbe />
</div>
</AuthGate>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '打开账号面板' }));
const accountDialog = await screen.findByRole('dialog', {
name: '账号信息',
});
await user.click(
within(accountDialog).getByRole('button', { name: '修改密码' }),
);
const passwordDialog = await screen.findByRole('dialog', {
name: '修改登录密码',
});
await user.type(within(passwordDialog).getByLabelText('当前密码'), 'oldpass1');
await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1');
await user.click(
within(passwordDialog).getByRole('button', { name: '确认修改密码' }),
);
await waitFor(() => {
expect(authMocks.changePassword).toHaveBeenCalledWith(
'oldpass1',
'newpass1',
);
expect(screen.getByText('当前用户:未登录')).toBeTruthy();
});
expect(screen.queryByRole('dialog', { name: '账号信息' })).toBeNull();
});