26555e66af
密码登录、验证码发送和验证码登录改用国家码与纯手机号字段 非 JSON 认证错误回退为中文操作提示 补充登录契约、错误回退测试和决策记录
401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import {
|
|
AuthenticatedClient,
|
|
expect,
|
|
fireEvent,
|
|
it,
|
|
React,
|
|
render,
|
|
screen,
|
|
testAuthUser,
|
|
vi,
|
|
} from './harness';
|
|
|
|
export function registerAuthTests() {
|
|
it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => {
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockImplementation(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === '/api/auth/refresh') {
|
|
return new Response(JSON.stringify({ token: 'fresh-token' }), {
|
|
status: 200,
|
|
});
|
|
}
|
|
if (url === '/api/auth/me') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
user: testAuthUser,
|
|
availableLoginMethods: ['password'],
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
|
|
render(
|
|
React.createElement(
|
|
React.StrictMode,
|
|
null,
|
|
React.createElement(AuthenticatedClient, null, ({ user }) =>
|
|
React.createElement(
|
|
'main',
|
|
{ 'aria-label': '已登录' },
|
|
user.displayName,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(await screen.findByLabelText('已登录')).not.toBeNull();
|
|
expect(
|
|
fetchSpy.mock.calls.filter(
|
|
([input]) => String(input) === '/api/auth/refresh',
|
|
),
|
|
).toHaveLength(1);
|
|
expect(
|
|
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
|
).toBe('fresh-token');
|
|
});
|
|
|
|
it('shows phone code login before entering the workspace', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
|
async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === '/api/auth/refresh') {
|
|
return new Response('', { status: 401 });
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
},
|
|
);
|
|
|
|
render(
|
|
React.createElement(AuthenticatedClient, null, () =>
|
|
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
|
|
),
|
|
);
|
|
|
|
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
|
|
expect(screen.getByRole('button', { name: '验证码登录' })).not.toBeNull();
|
|
expect(screen.getByRole('button', { name: '密码登录' })).not.toBeNull();
|
|
expect(screen.getByLabelText('手机号')).not.toBeNull();
|
|
expect(screen.getByLabelText('验证码')).not.toBeNull();
|
|
expect(screen.queryByLabelText('已登录')).toBeNull();
|
|
});
|
|
|
|
it('logs in with a phone code and stores the returned token', async () => {
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockImplementation(
|
|
async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(input);
|
|
if (url === '/api/auth/refresh') {
|
|
return new Response('', { status: 401 });
|
|
}
|
|
if (url === '/api/auth/phone/send-code') {
|
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
countryCode: '86',
|
|
purePhoneNumber: '13800000000',
|
|
scene: 'login',
|
|
});
|
|
return new Response(
|
|
JSON.stringify({
|
|
ok: true,
|
|
cooldownSeconds: 60,
|
|
expiresInSeconds: 300,
|
|
providerRequestId: 'sms-1',
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
if (url === '/api/auth/phone/login') {
|
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
countryCode: '86',
|
|
purePhoneNumber: '13800000000',
|
|
code: '123456',
|
|
});
|
|
return new Response(
|
|
JSON.stringify({
|
|
token: 'phone-token',
|
|
user: { ...testAuthUser, loginMethod: 'phone' },
|
|
created: false,
|
|
referral: null,
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
},
|
|
);
|
|
|
|
render(
|
|
React.createElement(AuthenticatedClient, null, ({ user }) =>
|
|
React.createElement(
|
|
'main',
|
|
{ 'aria-label': '已登录' },
|
|
user.loginMethod,
|
|
),
|
|
),
|
|
);
|
|
|
|
await screen.findByRole('main', { name: '登录' });
|
|
fireEvent.change(screen.getByLabelText('手机号'), {
|
|
target: { value: '+86 138 0000 0000' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '获取验证码' }));
|
|
expect(
|
|
await screen.findByText('验证码已发送,300 秒内有效'),
|
|
).not.toBeNull();
|
|
|
|
fireEvent.change(screen.getByLabelText('验证码'), {
|
|
target: { value: '123456' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
|
|
|
expect(await screen.findByLabelText('已登录')).not.toBeNull();
|
|
expect(screen.getByLabelText('已登录').textContent).toBe('phone');
|
|
expect(
|
|
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
|
).toBe('phone-token');
|
|
expect(
|
|
fetchSpy.mock.calls.filter(
|
|
([input]) => String(input) === '/api/auth/phone/send-code',
|
|
),
|
|
).toHaveLength(1);
|
|
expect(
|
|
fetchSpy.mock.calls.filter(
|
|
([input]) => String(input) === '/api/auth/phone/login',
|
|
),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it('logs in with the current password phone contract', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
|
async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(input);
|
|
if (url === '/api/auth/refresh') {
|
|
return new Response('', { status: 401 });
|
|
}
|
|
if (url === '/api/auth/entry') {
|
|
expect(JSON.parse(String(init?.body))).toEqual({
|
|
countryCode: '86',
|
|
purePhoneNumber: '13800000000',
|
|
password: 'secret123',
|
|
});
|
|
return new Response(
|
|
JSON.stringify({
|
|
token: 'password-token',
|
|
user: { ...testAuthUser, loginMethod: 'password' },
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
},
|
|
);
|
|
|
|
render(
|
|
React.createElement(AuthenticatedClient, null, ({ user }) =>
|
|
React.createElement(
|
|
'main',
|
|
{ 'aria-label': '已登录' },
|
|
user.loginMethod,
|
|
),
|
|
),
|
|
);
|
|
|
|
await screen.findByRole('main', { name: '登录' });
|
|
fireEvent.click(screen.getByRole('button', { name: '密码登录' }));
|
|
fireEvent.change(screen.getByLabelText('手机号'), {
|
|
target: { value: '+86 138 0000 0000' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('密码'), {
|
|
target: { value: ' secret123 ' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
|
|
|
expect(await screen.findByLabelText('已登录')).not.toBeNull();
|
|
expect(
|
|
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
|
).toBe('password-token');
|
|
});
|
|
|
|
it('falls back to the localized action error for non-JSON auth failures', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
|
async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === '/api/auth/refresh') {
|
|
return new Response('', { status: 401 });
|
|
}
|
|
if (url === '/api/auth/phone/login') {
|
|
return new Response(
|
|
'Failed to deserialize the JSON body into the target type',
|
|
{ status: 422, headers: { 'Content-Type': 'text/plain' } },
|
|
);
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
},
|
|
);
|
|
|
|
render(
|
|
React.createElement(AuthenticatedClient, null, () =>
|
|
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
|
|
),
|
|
);
|
|
|
|
await screen.findByRole('main', { name: '登录' });
|
|
fireEvent.change(screen.getByLabelText('手机号'), {
|
|
target: { value: '13800000000' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('验证码'), {
|
|
target: { value: '123456' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
|
|
|
expect(await screen.findByText('登录失败')).not.toBeNull();
|
|
expect(screen.queryByText(/Unexpected|Failed to deserialize/u)).toBeNull();
|
|
});
|
|
|
|
it('shows a clear login service error instead of raw Load failed', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
|
async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === '/api/auth/refresh') {
|
|
return new Response('', { status: 401 });
|
|
}
|
|
if (url === '/api/auth/phone/login') {
|
|
throw new TypeError('Load failed');
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
},
|
|
);
|
|
|
|
render(
|
|
React.createElement(AuthenticatedClient, null, () =>
|
|
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
|
|
),
|
|
);
|
|
|
|
await screen.findByRole('main', { name: '登录' });
|
|
fireEvent.change(screen.getByLabelText('手机号'), {
|
|
target: { value: '13800000000' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('验证码'), {
|
|
target: { value: '123456' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
|
),
|
|
).not.toBeNull();
|
|
expect(screen.queryByText(/Load failed/u)).toBeNull();
|
|
expect(screen.queryByLabelText('已登录')).toBeNull();
|
|
});
|
|
|
|
it('keeps the stored token when startup auth check cannot reach the service', async () => {
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'existing-token',
|
|
);
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockImplementation(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === '/api/auth/me') {
|
|
throw new TypeError('Load failed');
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
|
|
render(
|
|
React.createElement(AuthenticatedClient, null, ({ user }) =>
|
|
React.createElement(
|
|
'main',
|
|
{ 'aria-label': '已登录' },
|
|
user.displayName,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
|
|
expect(
|
|
screen.getByText(
|
|
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
|
),
|
|
).not.toBeNull();
|
|
expect(
|
|
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
|
).toBe('existing-token');
|
|
expect(screen.queryByLabelText('已登录')).toBeNull();
|
|
expect(
|
|
fetchSpy.mock.calls.filter(([input]) => String(input) === '/api/auth/me'),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it('still calls logout when token refresh fails during logout retry', async () => {
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'existing-token',
|
|
);
|
|
let logoutCalls = 0;
|
|
const fetchSpy = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockImplementation(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url === '/api/auth/me') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
user: testAuthUser,
|
|
availableLoginMethods: ['password'],
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
if (url === '/api/auth/logout') {
|
|
logoutCalls += 1;
|
|
return new Response('', { status: logoutCalls === 1 ? 500 : 200 });
|
|
}
|
|
if (url === '/api/auth/refresh') {
|
|
return new Response('', { status: 401 });
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
|
|
render(
|
|
React.createElement(AuthenticatedClient, null, ({ user, logout }) =>
|
|
React.createElement(
|
|
'main',
|
|
{ 'aria-label': '已登录' },
|
|
React.createElement('span', null, user.displayName),
|
|
React.createElement(
|
|
'button',
|
|
{ type: 'button', onClick: logout },
|
|
'退出',
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(await screen.findByLabelText('已登录')).not.toBeNull();
|
|
fireEvent.click(screen.getByRole('button', { name: '退出' }));
|
|
|
|
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
|
|
expect(screen.getByText('已退出登录')).not.toBeNull();
|
|
expect(
|
|
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
|
).toBe(null);
|
|
expect(
|
|
fetchSpy.mock.calls.filter(
|
|
([input]) => String(input) === '/api/auth/logout',
|
|
),
|
|
).toHaveLength(2);
|
|
expect(
|
|
fetchSpy.mock.calls.filter(
|
|
([input]) => String(input) === '/api/auth/refresh',
|
|
),
|
|
).toHaveLength(1);
|
|
});
|
|
}
|