Files
makelore/tests/unit/auth-store.test.ts
brother7 86ece3a430 实现客户端登录七天滑动续期
需求:解决短效访问令牌到期后客户端一小时掉登录的问题。

实现:由 Electron Main 加密管理并轮换刷新凭据,按真实用户活动续期,七天闲置后清理会话,并补齐并发、迁移和终态回归测试。
2026-08-07 16:11:10 +08:00

750 lines
22 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,
});
}
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('starts browser authorization through the host api and stores the 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,
},
});
await useAuthStore.getState().loginWithBrowser();
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/browser/start', {
method: 'POST',
});
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(window.localStorage.getItem('niancode-auth')).not.toContain(
'must-not-return-to-renderer-storage',
);
});
it('surfaces browser authorization failures and does not keep a partial session', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: false,
error: 'Authorization timed out',
});
await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow(
'Authorization timed out',
);
const state = useAuthStore.getState();
expect(state.loading).toBe(false);
expect(state.error).toBe('Authorization timed out');
expect(state.accessToken).toBeNull();
expect(state.isAuthenticated()).toBe(false);
});
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 delayed browser login revive a Main-terminal session', async () => {
let resolveLogin!: (value: unknown) => void;
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
resolveLogin = resolve;
}));
const login = useAuthStore.getState().loginWithBrowser();
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('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,
});
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,
});
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();
});
});