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, learning: 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('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, learning: true, 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, learning: true, 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: { 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({ 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, learning: true, robot: true, }); }); 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, learning: true, 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, learning: 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, learning: 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; }; 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 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, learning: 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, }); 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(); }); });