fix(auth): tighten refresh session revocation

This commit is contained in:
2026-05-13 15:04:37 +08:00
parent b13870f71b
commit 4fecf9c975
36 changed files with 1664 additions and 170 deletions

View File

@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { AuthUser } from '../../services/authService';
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
import { AuthGate } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
@@ -23,6 +23,10 @@ const authMocks = vi.hoisted(() => ({
logoutAuthUser: vi.fn(),
redeemRegistrationInviteCode: vi.fn(),
resetPassword: vi.fn(),
getAuthAuditLogs: vi.fn(),
getAuthRiskBlocks: vi.fn(),
getAuthSessions: vi.fn(),
revokeAuthSessions: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
consumeAuthCallbackResult: vi.fn(),
@@ -42,11 +46,11 @@ vi.mock('../../services/authService', () => ({
changePhoneNumber: vi.fn(),
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
getStoredLastLoginPhone: vi.fn(() => ''),
getAuthAuditLogs: vi.fn(),
getAuthAuditLogs: authMocks.getAuthAuditLogs,
getAuthLoginOptions: authMocks.getAuthLoginOptions,
getAuthRiskBlocks: vi.fn(),
getAuthRiskBlocks: authMocks.getAuthRiskBlocks,
getCurrentAuthUser: authMocks.getCurrentAuthUser,
getAuthSessions: vi.fn(),
getAuthSessions: authMocks.getAuthSessions,
getCaptchaChallengeFromError: vi.fn(() => null),
liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode,
@@ -54,7 +58,7 @@ vi.mock('../../services/authService', () => ({
logoutAuthUser: authMocks.logoutAuthUser,
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
revokeAuthSessions: authMocks.revokeAuthSessions,
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
setStoredLastLoginPhone: vi.fn(),
startWechatLogin: authMocks.startWechatLogin,
@@ -73,9 +77,12 @@ vi.mock('../../hooks/useGameSettings', () => ({
}),
}));
vi.mock('./AccountModal', () => ({
AccountModal: () => null,
}));
vi.mock('./AccountModal', async () => {
const actual =
await vi.importActual<typeof import('./AccountModal')>('./AccountModal');
return actual;
});
vi.mock('./BindPhoneScreen', () => ({
BindPhoneScreen: () => <div></div>,
@@ -116,6 +123,10 @@ beforeEach(() => {
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',
@@ -205,6 +216,21 @@ function LogoutStateProbe() {
);
}
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'],
@@ -786,3 +812,101 @@ test('auth gate separates sms and password login by tabs', async () => {
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,
clientType: 'web_browser',
clientRuntime: 'chrome',
clientPlatform: 'windows',
clientLabel: 'Windows / Chrome',
deviceDisplayName: 'Windows / Chrome',
miniProgramAppId: null,
miniProgramEnv: null,
userAgent: 'Mozilla/5.0',
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();
});