feat: add native password and sms login
This commit is contained in:
@@ -43,7 +43,7 @@ describe('auth store', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('starts browser authorization through the host api and stores the session', async () => {
|
||||
it('logs in with a password through the host api and stores the hydrated session', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
@@ -77,12 +77,21 @@ describe('auth store', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await useAuthStore.getState().loginWithBrowser();
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/browser/start', {
|
||||
method: 'POST',
|
||||
await useAuthStore.getState().loginWithPassword({
|
||||
username: 'zhangsan',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: 'zhangsan', password: 'secret' }),
|
||||
});
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me');
|
||||
expect(hostApiFetchMock).not.toHaveBeenCalledWith(
|
||||
'/api/auth/browser/start',
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated()).toBe(true);
|
||||
expect(state.accessToken).toBe('access-token');
|
||||
@@ -107,6 +116,91 @@ describe('auth store', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('logs in with a mobile code using only the phone and code payload', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
token: {
|
||||
access_token: 'mobile-access-token',
|
||||
token_type: 'Bearer',
|
||||
username: '13800138000',
|
||||
user_id: '2',
|
||||
},
|
||||
session: {
|
||||
accessToken: 'mobile-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
moduleAccess: { learning: false },
|
||||
});
|
||||
|
||||
await useAuthStore.getState().loginWithMobile({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
});
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/mobile-login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13800138000', code: '123456' }),
|
||||
});
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me');
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
accessToken: 'mobile-access-token',
|
||||
user: { username: '13800138000', userId: '2' },
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: false,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a new session with safe defaults when module hydration is temporarily unavailable', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
token: {
|
||||
access_token: 'access-token',
|
||||
token_type: 'Bearer',
|
||||
username: 'zhangsan',
|
||||
},
|
||||
session: {
|
||||
accessToken: 'access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('Host API unavailable'));
|
||||
|
||||
await useAuthStore.getState().loginWithPassword({
|
||||
username: 'zhangsan',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
accessToken: 'access-token',
|
||||
moduleAccess: {
|
||||
programming: true,
|
||||
design: true,
|
||||
learning: true,
|
||||
robot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes module access while restoring the session and defaults missing keys to enabled', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
@@ -226,7 +320,10 @@ describe('auth store', () => {
|
||||
details: { status: 401 },
|
||||
}));
|
||||
|
||||
await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow(
|
||||
await expect(useAuthStore.getState().loginWithPassword({
|
||||
username: 'zhangsan',
|
||||
password: 'secret',
|
||||
})).rejects.toThrow(
|
||||
'登录已过期,请重新授权。',
|
||||
);
|
||||
|
||||
@@ -244,23 +341,47 @@ describe('auth store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces browser authorization failures and does not keep a partial session', async () => {
|
||||
it('surfaces password login failures and does not keep a partial session', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Authorization timed out',
|
||||
error: 'Invalid credentials',
|
||||
});
|
||||
|
||||
await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow(
|
||||
'Authorization timed out',
|
||||
await expect(useAuthStore.getState().loginWithPassword({
|
||||
username: 'zhangsan',
|
||||
password: 'wrong',
|
||||
})).rejects.toThrow(
|
||||
'Invalid credentials',
|
||||
);
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.error).toBe('Authorization timed out');
|
||||
expect(state.error).toBe('Invalid credentials');
|
||||
expect(state.accessToken).toBeNull();
|
||||
expect(state.isAuthenticated()).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an invalid mobile login snapshot without retaining token data', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
token: { access_token: 'partial-token', username: '13800138000' },
|
||||
session: { accessToken: 'partial-token' },
|
||||
});
|
||||
|
||||
await expect(useAuthStore.getState().loginWithMobile({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
})).rejects.toThrow('Login failed');
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
loading: false,
|
||||
error: 'Login failed',
|
||||
accessToken: null,
|
||||
user: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a persisted session when the configured SSO gateway changes', async () => {
|
||||
useAuthStore.setState({
|
||||
initialized: false,
|
||||
@@ -476,13 +597,16 @@ describe('auth store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let delayed browser login revive a Main-terminal session', async () => {
|
||||
it('does not let a delayed password login revive a Main-terminal session', async () => {
|
||||
let resolveLogin!: (value: unknown) => void;
|
||||
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveLogin = resolve;
|
||||
}));
|
||||
|
||||
const login = useAuthStore.getState().loginWithBrowser();
|
||||
const login = useAuthStore.getState().loginWithPassword({
|
||||
username: 'zhangsan',
|
||||
password: 'secret',
|
||||
});
|
||||
useAuthStore.getState().applyMainSession(null);
|
||||
resolveLogin({
|
||||
success: true,
|
||||
|
||||
Reference in New Issue
Block a user