// Tests for checkAuth() — the route-auth seam in src/utils/authGuard.js. // // Mocking strategy: // - Mock @/utils/lanAccess.js once at the top so we control LAN vs non-LAN // per-test (real isLanAccess() depends on window.location.hostname which // jsdom does not behave like a real browser for). // - Build authState and route objects inline per test — no Pinia setup. // - All 4 authState members use vi.fn() so we can assert call counts. import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@/utils/lanAccess.js', () => ({ isLanAccess: vi.fn(() => false) })) import { isLanAccess } from '@/utils/lanAccess.js' import { checkAuth } from '@/utils/authGuard.js' beforeEach(() => { isLanAccess.mockReturnValue(false) }) const makeAuthState = (overrides = {}) => ({ isAuthenticated: false, isAccessTokenExpiringSoon: () => false, refreshAccessToken: vi.fn(), logout: vi.fn(), ...overrides, }) const makeRoute = (overrides = {}) => ({ path: '/some-protected', meta: { requiresAuth: true }, ...overrides, }) // ─── LAN bypass ───────────────────────────────────────────────────── describe('LAN bypass', () => { it('allows any route when isLanAccess() returns true', async () => { isLanAccess.mockReturnValueOnce(true) const state = makeAuthState({ isAuthenticated: vi.fn(() => false) }) const route = makeRoute({ path: '/tasks', meta: { requiresAuth: true } }) const decision = await checkAuth(route, state) expect(decision).toEqual({ allowed: true, reason: 'LAN_BYPASS' }) expect(state.isAuthenticated).not.toHaveBeenCalled() expect(state.refreshAccessToken).not.toHaveBeenCalled() expect(state.logout).not.toHaveBeenCalled() }) it('LAN-bypasses /login even when not authenticated', async () => { isLanAccess.mockReturnValueOnce(true) const state = makeAuthState() const decision = await checkAuth({ path: '/login', meta: { requiresAuth: false } }, state) expect(decision).toEqual({ allowed: true, reason: 'LAN_BYPASS' }) }) }) // ─── requiresAuth: false (login route) ────────────────────────────── describe('requiresAuth: false', () => { it('passes /login through when not authenticated', async () => { const state = makeAuthState() const route = { path: '/login', meta: { requiresAuth: false } } const decision = await checkAuth(route, state) expect(decision).toEqual({ allowed: true, reason: 'PASS' }) }) it('redirects /login → / when already authenticated', async () => { const state = makeAuthState({ isAuthenticated: true }) const route = { path: '/login', meta: { requiresAuth: false } } const decision = await checkAuth(route, state) expect(decision).toEqual({ allowed: false, reason: 'ALREADY_AUTHED_ON_LOGIN', redirect: '/', }) }) }) // ─── requiresAuth: true (protected route) ─────────────────────────── describe('requiresAuth: true', () => { it('redirects protected route → /login when not authenticated', async () => { const state = makeAuthState() const route = makeRoute() const decision = await checkAuth(route, state) expect(decision).toEqual({ allowed: false, reason: 'NOT_AUTHENTICATED', redirect: '/login', }) }) it('passes protected route when authenticated and not expiring', async () => { const state = makeAuthState({ isAuthenticated: true }) const route = makeRoute() const decision = await checkAuth(route, state) expect(decision).toEqual({ allowed: true, reason: 'PASS' }) expect(state.refreshAccessToken).not.toHaveBeenCalled() }) it('passes protected route when isAccessTokenExpiringSoon is undefined', async () => { const state = makeAuthState({ isAuthenticated: true, isAccessTokenExpiringSoon: undefined, }) const route = makeRoute() const decision = await checkAuth(route, state) expect(decision).toEqual({ allowed: true, reason: 'PASS' }) expect(state.refreshAccessToken).not.toHaveBeenCalled() }) }) // ─── Proactive refresh ────────────────────────────────────────────── describe('proactive refresh', () => { it('refreshes when isAccessTokenExpiringSoon() returns true', async () => { const state = makeAuthState({ isAuthenticated: true, isAccessTokenExpiringSoon: () => true, }) state.refreshAccessToken.mockResolvedValueOnce('new-token') const route = makeRoute() const decision = await checkAuth(route, state) expect(state.refreshAccessToken).toHaveBeenCalledTimes(1) expect(decision).toEqual({ allowed: true, reason: 'PASS' }) }) it('does NOT refresh on /login even when authenticated and expiring', async () => { const state = makeAuthState({ isAuthenticated: true, isAccessTokenExpiringSoon: () => true, }) const route = { path: '/login', meta: { requiresAuth: false } } const decision = await checkAuth(route, state) expect(state.refreshAccessToken).not.toHaveBeenCalled() expect(decision).toEqual({ allowed: false, reason: 'ALREADY_AUTHED_ON_LOGIN', redirect: '/', }) }) it('returns REFRESH_FAILED and calls logout when refresh throws', async () => { const state = makeAuthState({ isAuthenticated: true, isAccessTokenExpiringSoon: () => true, }) state.refreshAccessToken.mockRejectedValueOnce(new Error('refresh failed')) const route = makeRoute() const decision = await checkAuth(route, state) expect(state.refreshAccessToken).toHaveBeenCalledTimes(1) expect(state.logout).toHaveBeenCalledTimes(1) expect(decision).toEqual({ allowed: false, reason: 'REFRESH_FAILED', redirect: '/login', }) }) it('does not throw when logout itself throws during REFRESH_FAILED', async () => { const state = makeAuthState({ isAuthenticated: true, isAccessTokenExpiringSoon: () => true, }) state.refreshAccessToken.mockRejectedValueOnce(new Error('refresh failed')) state.logout.mockRejectedValueOnce(new Error('logout failed')) const route = makeRoute() const decision = await checkAuth(route, state) expect(decision).toEqual({ allowed: false, reason: 'REFRESH_FAILED', redirect: '/login', }) }) }) // ─── Edge cases ───────────────────────────────────────────────────── describe('edge cases', () => { it.each([ ['/', '/'], ['/profile', '/profile'], ['/tasks', '/tasks'], ['/settings', '/settings'], ['/master-data/students', '/master-data/students'], ])('redirects %s → /login when protected and not authed', async (path) => { const state = makeAuthState() const route = { path, meta: { requiresAuth: true } } const decision = await checkAuth(route, state) expect(decision).toEqual({ allowed: false, reason: 'NOT_AUTHENTICATED', redirect: '/login', }) }) it('treats route missing meta.requiresAuth as not protected', async () => { const state = makeAuthState() const route = { path: '/whatever', meta: {} } const decision = await checkAuth(route, state) expect(decision).toEqual({ allowed: true, reason: 'PASS' }) }) it('LAN takes precedence — never reads isAuthenticated when LAN', async () => { isLanAccess.mockReturnValueOnce(true) const state = makeAuthState({ isAuthenticated: vi.fn(() => false) }) const route = makeRoute() await checkAuth(route, state) expect(state.isAuthenticated).not.toHaveBeenCalled() }) it('handles authState.logout being undefined', async () => { const state = makeAuthState({ isAuthenticated: true, isAccessTokenExpiringSoon: () => true, logout: undefined, }) state.refreshAccessToken.mockRejectedValueOnce(new Error('refresh failed')) const route = makeRoute() const decision = await checkAuth(route, state) expect(decision).toEqual({ allowed: false, reason: 'REFRESH_FAILED', redirect: '/login', }) }) it('decision shape: PASS has {allowed, reason} only', async () => { const state = makeAuthState() const route = { path: '/login', meta: { requiresAuth: false } } const decision = await checkAuth(route, state) expect(Object.keys(decision).sort()).toEqual(['allowed', 'reason']) }) it('decision shape: redirects have {allowed, reason, redirect}', async () => { const state = makeAuthState() const route = makeRoute() const decision = await checkAuth(route, state) expect(Object.keys(decision).sort()).toEqual(['allowed', 'reason', 'redirect']) }) })