1234 lines
34 KiB
TypeScript
1234 lines
34 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
|
|
const hostApiFetchMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('@/lib/host-api', () => ({
|
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
|
}));
|
|
|
|
function resetAuthStore() {
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
loading: false,
|
|
error: null,
|
|
authBase: '',
|
|
clientId: 'app',
|
|
accessToken: null,
|
|
tokenType: null,
|
|
expiresAt: null,
|
|
lastActiveAt: null,
|
|
canRefresh: false,
|
|
legacyRefreshToken: null,
|
|
user: null,
|
|
moduleAccess: {
|
|
programming: true,
|
|
design: true,
|
|
robot: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
describe('auth store', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-05-15T08:00:00.000Z'));
|
|
window.localStorage.clear();
|
|
hostApiFetchMock.mockReset();
|
|
resetAuthStore();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('does not treat an orphaned access token as an authenticated identity', async () => {
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
accessToken: 'orphaned-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
user: null,
|
|
});
|
|
|
|
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
|
|
await expect(useAuthStore.getState().getValidAccessToken()).resolves.toBeNull();
|
|
expect(hostApiFetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('logs in with a password through the host api and stores the hydrated session', async () => {
|
|
hostApiFetchMock
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
token: {
|
|
access_token: 'access-token',
|
|
refresh_token: 'must-not-return-to-renderer-storage',
|
|
token_type: 'Bearer',
|
|
expires_in: 60,
|
|
username: 'zhangsan',
|
|
user_id: '1',
|
|
tenant_id: 7,
|
|
dept_id: 9,
|
|
authorities: ['ROLE_USER'],
|
|
client_id: 'app',
|
|
},
|
|
session: {
|
|
accessToken: 'access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
moduleAccess: {
|
|
programming: true,
|
|
design: false,
|
|
robot: false,
|
|
},
|
|
});
|
|
|
|
await useAuthStore.getState().loginWithPassword({
|
|
username: 'zhangsan',
|
|
password: 'secret',
|
|
rememberPassword: true,
|
|
});
|
|
|
|
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
username: 'zhangsan',
|
|
password: 'secret',
|
|
rememberPassword: true,
|
|
}),
|
|
});
|
|
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');
|
|
expect(state.canRefresh).toBe(true);
|
|
expect(state.legacyRefreshToken).toBeNull();
|
|
expect(state.expiresAt).toBe(Date.now() + 60_000);
|
|
expect(state.user).toEqual({
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
});
|
|
expect(state.moduleAccess).toEqual({
|
|
programming: true,
|
|
design: false,
|
|
robot: false,
|
|
});
|
|
expect(window.localStorage.getItem('niancode-auth')).not.toContain(
|
|
'must-not-return-to-renderer-storage',
|
|
);
|
|
expect(window.localStorage.getItem('niancode-auth')).not.toContain('secret');
|
|
});
|
|
|
|
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: { robot: 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,
|
|
robot: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
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,
|
|
robot: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('refreshes module access while restoring the session and defaults missing keys to enabled', async () => {
|
|
hostApiFetchMock
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
moduleAccess: { design: false },
|
|
});
|
|
useAuthStore.setState({
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: null,
|
|
deptId: null,
|
|
authorities: [],
|
|
},
|
|
});
|
|
|
|
await useAuthStore.getState().init();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me');
|
|
expect(useAuthStore.getState().moduleAccess).toEqual({
|
|
programming: true,
|
|
design: false,
|
|
robot: true,
|
|
});
|
|
});
|
|
|
|
it('rehydrates a missing persisted identity from the current-user projection', async () => {
|
|
hostApiFetchMock
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
user: {
|
|
username: 'restored-user',
|
|
userId: '42',
|
|
tenantId: 7,
|
|
deptId: 'engineering',
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
moduleAccess: { design: false },
|
|
});
|
|
useAuthStore.setState({
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
user: null,
|
|
});
|
|
|
|
await useAuthStore.getState().init();
|
|
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
initialized: true,
|
|
loading: false,
|
|
error: null,
|
|
user: {
|
|
username: 'restored-user',
|
|
userId: '42',
|
|
tenantId: 7,
|
|
deptId: 'engineering',
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
moduleAccess: {
|
|
programming: true,
|
|
design: false,
|
|
robot: true,
|
|
},
|
|
});
|
|
expect(useAuthStore.getState().isAuthenticated()).toBe(true);
|
|
});
|
|
|
|
it('clears a synchronized session when no user identity can be recovered', async () => {
|
|
hostApiFetchMock
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'orphaned-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
moduleAccess: { programming: true },
|
|
})
|
|
.mockResolvedValueOnce({ success: true });
|
|
useAuthStore.setState({
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
accessToken: 'orphaned-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
user: null,
|
|
});
|
|
|
|
await useAuthStore.getState().init();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
|
3,
|
|
'/api/auth/session/clear',
|
|
{ method: 'POST' },
|
|
);
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
initialized: true,
|
|
loading: false,
|
|
error: '登录身份无法确认,请重新登录。',
|
|
accessToken: null,
|
|
user: null,
|
|
canRefresh: false,
|
|
});
|
|
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
|
|
});
|
|
|
|
it('fails closed when session sync is unavailable and only an orphaned token was persisted', async () => {
|
|
hostApiFetchMock
|
|
.mockRejectedValueOnce(new Error('Host API unavailable'))
|
|
.mockResolvedValueOnce({ success: true });
|
|
useAuthStore.setState({
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
accessToken: 'orphaned-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
user: null,
|
|
});
|
|
|
|
await useAuthStore.getState().init();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/api/auth/session/clear',
|
|
{ method: 'POST' },
|
|
);
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
initialized: true,
|
|
loading: false,
|
|
error: '登录身份无法确认,请重新登录。',
|
|
accessToken: null,
|
|
user: null,
|
|
});
|
|
});
|
|
|
|
it('clears restored auth when the current-user policy lookup is unauthorized', async () => {
|
|
hostApiFetchMock
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
})
|
|
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), {
|
|
details: { status: 401 },
|
|
}));
|
|
useAuthStore.setState({
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
moduleAccess: {
|
|
programming: true,
|
|
design: false,
|
|
robot: true,
|
|
},
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: null,
|
|
deptId: null,
|
|
authorities: [],
|
|
},
|
|
});
|
|
|
|
await useAuthStore.getState().init();
|
|
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
initialized: true,
|
|
loading: false,
|
|
error: '登录已过期,请重新授权。',
|
|
accessToken: null,
|
|
user: null,
|
|
moduleAccess: {
|
|
programming: true,
|
|
design: true,
|
|
robot: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('rejects a new login when its current-user policy lookup is unauthorized', async () => {
|
|
hostApiFetchMock
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
token: {
|
|
access_token: 'access-token',
|
|
token_type: 'Bearer',
|
|
username: 'zhangsan',
|
|
user_id: '1',
|
|
},
|
|
session: {
|
|
accessToken: 'access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
})
|
|
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), {
|
|
details: { status: 401 },
|
|
}));
|
|
|
|
await expect(useAuthStore.getState().loginWithPassword({
|
|
username: 'zhangsan',
|
|
password: 'secret',
|
|
})).rejects.toThrow(
|
|
'登录已过期,请重新授权。',
|
|
);
|
|
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
initialized: false,
|
|
loading: false,
|
|
accessToken: null,
|
|
user: null,
|
|
moduleAccess: {
|
|
programming: true,
|
|
design: true,
|
|
robot: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('surfaces password login failures and does not keep a partial session', async () => {
|
|
hostApiFetchMock.mockResolvedValueOnce({
|
|
success: false,
|
|
error: 'Invalid credentials',
|
|
});
|
|
|
|
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('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,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://onefeel.brother7.cn/ingress/auth',
|
|
clientId: 'app',
|
|
accessToken: 'old-environment-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
canRefresh: true,
|
|
legacyRefreshToken: 'old-environment-refresh-token',
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
await useAuthStore.getState().init();
|
|
|
|
const state = useAuthStore.getState();
|
|
expect(state.authBase).toBe('https://biz.nianxx.cn/auth/');
|
|
expect(state.accessToken).toBeNull();
|
|
expect(state.canRefresh).toBe(false);
|
|
expect(state.legacyRefreshToken).toBeNull();
|
|
expect(state.user).toBeNull();
|
|
expect(state.isAuthenticated()).toBe(false);
|
|
});
|
|
|
|
it('migrates a legacy persisted refresh token to Main during init', async () => {
|
|
const lastActiveAt = Date.now() - 60_000;
|
|
hostApiFetchMock.mockResolvedValueOnce({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
legacyRefreshToken: 'persisted-refresh-token',
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
await useAuthStore.getState().init();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/sync', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
accessToken: 'persisted-access-token',
|
|
refreshToken: 'persisted-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt,
|
|
}),
|
|
});
|
|
expect(useAuthStore.getState().legacyRefreshToken).toBeNull();
|
|
const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as {
|
|
state?: Record<string, unknown>;
|
|
};
|
|
expect(persisted.state?.legacyRefreshToken).toBeNull();
|
|
});
|
|
|
|
it('waits for Main session sync before marking persisted auth initialized', async () => {
|
|
let resolveSync!: (value: unknown) => void;
|
|
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
|
|
resolveSync = resolve;
|
|
}));
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
canRefresh: true,
|
|
legacyRefreshToken: null,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
const initialization = useAuthStore.getState().init();
|
|
|
|
expect(useAuthStore.getState().initialized).toBe(false);
|
|
|
|
resolveSync({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
await initialization;
|
|
|
|
expect(useAuthStore.getState().initialized).toBe(true);
|
|
});
|
|
|
|
it('retains persisted renderer auth when Main session sync is temporarily unavailable', async () => {
|
|
hostApiFetchMock.mockRejectedValueOnce(new Error('Host API unavailable'));
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
canRefresh: true,
|
|
legacyRefreshToken: 'persisted-refresh-token',
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
await useAuthStore.getState().init();
|
|
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
initialized: true,
|
|
accessToken: 'persisted-access-token',
|
|
canRefresh: true,
|
|
legacyRefreshToken: 'persisted-refresh-token',
|
|
user: { username: 'zhangsan' },
|
|
error: null,
|
|
});
|
|
const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as {
|
|
state?: Record<string, unknown>;
|
|
};
|
|
expect(persisted.state?.legacyRefreshToken).toBe('persisted-refresh-token');
|
|
});
|
|
|
|
it('does not let a delayed init revive a session after logout', async () => {
|
|
let resolveSync!: (value: unknown) => void;
|
|
hostApiFetchMock
|
|
.mockImplementationOnce(() => new Promise((resolve) => {
|
|
resolveSync = resolve;
|
|
}))
|
|
.mockResolvedValueOnce({ success: true });
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
legacyRefreshToken: 'persisted-refresh-token',
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
const initialization = useAuthStore.getState().init();
|
|
await useAuthStore.getState().logout();
|
|
resolveSync({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'late-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
await initialization;
|
|
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
initialized: true,
|
|
loading: false,
|
|
accessToken: null,
|
|
canRefresh: false,
|
|
legacyRefreshToken: null,
|
|
user: null,
|
|
});
|
|
});
|
|
|
|
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().loginWithPassword({
|
|
username: 'zhangsan',
|
|
password: 'secret',
|
|
});
|
|
useAuthStore.getState().applyMainSession(null);
|
|
resolveLogin({
|
|
success: true,
|
|
token: { access_token: 'late-access-token', username: 'zhangsan' },
|
|
session: {
|
|
accessToken: 'late-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
await login;
|
|
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
initialized: true,
|
|
loading: false,
|
|
accessToken: null,
|
|
canRefresh: false,
|
|
user: null,
|
|
});
|
|
});
|
|
|
|
it('does not let a delayed refresh revive an invalidated session', async () => {
|
|
let resolveRefresh!: (value: unknown) => void;
|
|
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
|
|
resolveRefresh = resolve;
|
|
}));
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
accessToken: 'old-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() - 1,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
const refresh = useAuthStore.getState().refreshSession();
|
|
useAuthStore.getState().invalidateSession('登录已失效');
|
|
resolveRefresh({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'late-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
|
|
await expect(refresh).resolves.toBeNull();
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
accessToken: null,
|
|
canRefresh: false,
|
|
user: null,
|
|
error: '登录已失效',
|
|
});
|
|
});
|
|
|
|
it('does not let delayed activity revive an invalidated session', async () => {
|
|
let resolveActivity!: (value: unknown) => void;
|
|
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
|
|
resolveActivity = resolve;
|
|
}));
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
accessToken: 'access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now() - 60_000,
|
|
canRefresh: true,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
const activity = useAuthStore.getState().markActivity();
|
|
useAuthStore.getState().invalidateSession('登录已失效');
|
|
resolveActivity({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'late-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
await activity;
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
accessToken: null,
|
|
canRefresh: false,
|
|
user: null,
|
|
error: '登录已失效',
|
|
});
|
|
});
|
|
|
|
it('refreshes the Main-managed session and preserves the user profile', async () => {
|
|
const lastActiveAt = Date.now() - 60_000;
|
|
hostApiFetchMock.mockResolvedValueOnce({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'new-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 120_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'old-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 1_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
legacyRefreshToken: null,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
await expect(useAuthStore.getState().refreshSession()).resolves.toBe('new-access-token');
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/refresh', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ forceRefresh: true }),
|
|
});
|
|
expect(useAuthStore.getState().user?.username).toBe('zhangsan');
|
|
});
|
|
|
|
it('clears auth when a refreshed session cannot read the current user', async () => {
|
|
hostApiFetchMock
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'new-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 120_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
})
|
|
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), {
|
|
details: { status: 401 },
|
|
}));
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
accessToken: 'old-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 1_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: null,
|
|
deptId: null,
|
|
authorities: [],
|
|
},
|
|
});
|
|
|
|
await expect(useAuthStore.getState().refreshSession()).resolves.toBeNull();
|
|
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
error: '登录已过期,请重新授权。',
|
|
accessToken: null,
|
|
user: null,
|
|
moduleAccess: {
|
|
programming: true,
|
|
design: true,
|
|
robot: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('restores an expired access token when Main reports it can refresh', async () => {
|
|
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
|
hostApiFetchMock
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'expired-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() - 1,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'renewed-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'expired-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() - 1,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
legacyRefreshToken: null,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
expect(useAuthStore.getState().isAuthenticated()).toBe(true);
|
|
await useAuthStore.getState().init();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/session/sync', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
accessToken: 'expired-access-token',
|
|
refreshToken: null,
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() - 1,
|
|
lastActiveAt,
|
|
}),
|
|
});
|
|
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/session/refresh', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ forceRefresh: false }),
|
|
});
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
initialized: true,
|
|
accessToken: 'renewed-access-token',
|
|
canRefresh: true,
|
|
legacyRefreshToken: null,
|
|
lastActiveAt,
|
|
});
|
|
});
|
|
|
|
it('clears a session when Main rejects it after seven inactive days', async () => {
|
|
hostApiFetchMock.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), {
|
|
details: { status: 401 },
|
|
}));
|
|
useAuthStore.setState({
|
|
initialized: false,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt: Date.now() - 7 * 24 * 60 * 60 * 1000,
|
|
canRefresh: true,
|
|
legacyRefreshToken: null,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
await useAuthStore.getState().init();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/sync', expect.any(Object));
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
initialized: true,
|
|
accessToken: null,
|
|
canRefresh: false,
|
|
legacyRefreshToken: null,
|
|
lastActiveAt: null,
|
|
user: null,
|
|
});
|
|
});
|
|
|
|
it('persists refresh capability without persisting a new refresh token', async () => {
|
|
hostApiFetchMock.mockResolvedValueOnce({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'new-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
accessToken: 'old-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() - 1,
|
|
lastActiveAt: Date.now(),
|
|
canRefresh: true,
|
|
legacyRefreshToken: null,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: null,
|
|
deptId: null,
|
|
authorities: [],
|
|
},
|
|
});
|
|
|
|
await expect(useAuthStore.getState().refreshSession()).resolves.toBe('new-access-token');
|
|
|
|
const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as {
|
|
state?: Record<string, unknown>;
|
|
};
|
|
expect(persisted.state).not.toHaveProperty('refreshToken');
|
|
expect(persisted.state).toMatchObject({ canRefresh: true, legacyRefreshToken: null });
|
|
});
|
|
|
|
it('does not advance activity while maintaining the session in the background', async () => {
|
|
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
|
hostApiFetchMock.mockResolvedValueOnce({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'background-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
accessToken: 'old-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 1_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
legacyRefreshToken: null,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: null,
|
|
deptId: null,
|
|
authorities: [],
|
|
},
|
|
});
|
|
|
|
await useAuthStore.getState().maintainSession();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/session/refresh', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ forceRefresh: false }),
|
|
});
|
|
expect(useAuthStore.getState().lastActiveAt).toBe(lastActiveAt);
|
|
});
|
|
|
|
it('persists Main-owned refresh capability received outside a Renderer refresh', () => {
|
|
const lastActiveAt = Date.now() - 60_000;
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
accessToken: 'old-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
legacyRefreshToken: null,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
useAuthStore.getState().applyMainSession({
|
|
accessToken: 'main-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
});
|
|
|
|
const persisted = JSON.parse(window.localStorage.getItem('niancode-auth') || '{}') as {
|
|
state?: Record<string, unknown>;
|
|
};
|
|
expect(useAuthStore.getState().user?.username).toBe('zhangsan');
|
|
expect(useAuthStore.getState().lastActiveAt).toBe(lastActiveAt);
|
|
expect(persisted.state).not.toHaveProperty('refreshToken');
|
|
expect(persisted.state).toMatchObject({ canRefresh: true, legacyRefreshToken: null });
|
|
});
|
|
|
|
it('clears stale renderer auth when refresh fails', async () => {
|
|
hostApiFetchMock.mockRejectedValueOnce(new Error('Invalid refresh token'));
|
|
useAuthStore.setState({
|
|
initialized: true,
|
|
loading: false,
|
|
error: null,
|
|
authBase: 'https://biz.nianxx.cn/auth/',
|
|
clientId: 'app',
|
|
accessToken: 'expired-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() - 1,
|
|
canRefresh: true,
|
|
legacyRefreshToken: null,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
await expect(useAuthStore.getState().refreshSession()).resolves.toBeNull();
|
|
|
|
expect(useAuthStore.getState()).toMatchObject({
|
|
accessToken: null,
|
|
canRefresh: false,
|
|
legacyRefreshToken: null,
|
|
user: null,
|
|
});
|
|
});
|
|
|
|
it('logs out remotely and clears the local session', async () => {
|
|
hostApiFetchMock.mockResolvedValueOnce({ success: true });
|
|
useAuthStore.setState({
|
|
authBase: 'https://gateway.example.com/auth',
|
|
clientId: 'app',
|
|
accessToken: 'access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 60_000,
|
|
canRefresh: true,
|
|
legacyRefreshToken: null,
|
|
user: {
|
|
username: 'zhangsan',
|
|
userId: '1',
|
|
tenantId: 7,
|
|
deptId: 9,
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
});
|
|
|
|
await useAuthStore.getState().logout();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/logout', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
accessToken: 'access-token',
|
|
}),
|
|
});
|
|
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
|
|
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
expect(useAuthStore.getState().user).toBeNull();
|
|
});
|
|
});
|