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; }; 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; }; 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; }; 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; }; 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(); }); });