Commit 4bca327d authored by Administrator's avatar Administrator
Browse files

test: seam coverage for auth/authService/endpoints/lanAccess — Candidate 4



Five new spec files take the test count 113 → 178 (+65). Zero
production code changes; all coverage gain.

- src/__tests__/utils/jwtFactory.js           — base64url JWT maker
- src/__tests__/utils/authGuard.spec.js       — 21 tests on checkAuth()
                                               (LAN bypass, already-authed,
                                                refresh-failed, etc.)
- src/__tests__/utils/lanAccess.spec.js       — 17 tests on real CIDR
                                               parser (jsdom hostname
                                               patching)
- src/__tests__/api/endpoints.spec.js         — 10 tests, structural
                                               lock for apiEndpoints
- src/__tests__/api/authService.spec.js       — 5 tests, login +
                                               refreshAccessToken HTTP
                                               shape
- src/__tests__/stores/authStore.spec.js      — 33 tests, first Pinia
                                               spec; covers axios
                                               interceptor 8-branch
                                               decision tree including
                                               the LAN GET-bypass that
                                               had 0 coverage before

Footnotes for future explorers:
- auth.js reads localStorage at module-eval time to seed its initial
  ref values — vi.stubGlobal('localStorage', ...) installed in
  beforeEach is too late. Manipulate jsdom's real localStorage and
  clear() it in beforeEach instead.
- The axios response interceptor's logout() call is closure-captured
  at setup time, so external reassignment of auth.logout won't
  intercept it. Assert logout side effects via state changes
  (auth.accessToken === null, etc.) instead.
Co-Authored-By: default avatarClaude <noreply@anthropic.com>
parent cb14a136
// Contract tests for src/api/authService.js. Mirrors taskService.test.js:
// mock the apiClient module and assert the HTTP call shape + return value.
// Both login and refreshAccessToken rethrow the original error verbatim so
// callers (the Pinia store) can read error.response.data.detail — verify
// that the rethrow preserves the same Error instance.
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/api/index.js', () => ({
default: { post: vi.fn() },
apiEndpoints: {
AUTH: {
LOGIN: '/api/token/',
REFRESH: '/api/token/refresh/',
},
},
}))
import apiClient from '@/api/index.js'
import { login, refreshAccessToken } from '@/api/authService.js'
beforeEach(() => {
vi.clearAllMocks()
})
describe('login', () => {
it('POSTs /api/token/ with credentials and returns response.data', async () => {
apiClient.post.mockResolvedValueOnce({
data: { access: 'A', refresh: 'R' },
})
const out = await login({ username: 'alice', password: 'pw' })
expect(apiClient.post).toHaveBeenCalledTimes(1)
expect(apiClient.post).toHaveBeenCalledWith('/api/token/', {
username: 'alice',
password: 'pw',
})
expect(out).toEqual({ access: 'A', refresh: 'R' })
})
it('rethrows the original axios error (preserves error.response) on 401', async () => {
const axiosError = Object.assign(new Error('Request failed'), {
response: { status: 401, data: { detail: 'Invalid credentials' } },
})
apiClient.post.mockRejectedValueOnce(axiosError)
await expect(login({ username: 'u', password: 'bad' })).rejects.toBe(
axiosError,
)
})
it('rethrows network errors that have no response attached', async () => {
const netErr = new Error('Network Error')
apiClient.post.mockRejectedValueOnce(netErr)
await expect(login({ username: 'u', password: 'p' })).rejects.toBe(netErr)
})
})
describe('refreshAccessToken', () => {
it('POSTs /api/token/refresh/ with {refresh} and returns response.data', async () => {
apiClient.post.mockResolvedValueOnce({ data: { access: 'NEW' } })
const out = await refreshAccessToken('OLD-REFRESH')
expect(apiClient.post).toHaveBeenCalledTimes(1)
expect(apiClient.post).toHaveBeenCalledWith('/api/token/refresh/', {
refresh: 'OLD-REFRESH',
})
expect(out).toEqual({ access: 'NEW' })
})
it('rethrows the original error when refresh fails', async () => {
const axiosError = Object.assign(new Error('refresh failed'), {
response: { status: 401, data: { detail: 'Token expired' } },
})
apiClient.post.mockRejectedValueOnce(axiosError)
await expect(refreshAccessToken('OLD')).rejects.toBe(axiosError)
})
})
\ No newline at end of file
// Structural lock for src/api/endpoints.js — the single source of truth
// for every backend URL. Deliberately checks SHAPE (string-vs-function),
// not exact path templates (those are pinned by taskService.test.js and
// authService.spec.js). Add a new entity here when you add a new key.
import { describe, it, expect } from 'vitest'
import { apiEndpoints } from '@/api/endpoints.js'
describe('apiEndpoints top-level shape', () => {
it('exposes AUTH, STUDENTS, SUBJECTS, TERMS, STORIES, TASKS', () => {
expect(Object.keys(apiEndpoints).sort()).toEqual(
['AUTH', 'STORIES', 'STUDENTS', 'SUBJECTS', 'TASKS', 'TERMS'],
)
})
it('AUTH endpoints are plain /api/ strings', () => {
expect(typeof apiEndpoints.AUTH.LOGIN).toBe('string')
expect(typeof apiEndpoints.AUTH.REFRESH).toBe('string')
expect(apiEndpoints.AUTH.LOGIN).toMatch(/^\/api\//)
expect(apiEndpoints.AUTH.REFRESH).toMatch(/^\/api\//)
})
})
describe('apiEndpoints entity resources', () => {
// Walk each top-level entity recursively: every leaf must be a string
// starting with `/api/`, or a function returning such a string. Nested
// groups (TASKS.IMAGES) are recursed into.
it.each(['STUDENTS', 'SUBJECTS', 'TERMS', 'STORIES', 'TASKS'])(
'%s: every leaf is a /api/ string or a function returning one',
(entity) => {
const walk = (node, path = entity) => {
for (const [k, v] of Object.entries(node)) {
const here = `${path}.${k}`
if (typeof v === 'string') {
expect(v, here).toMatch(/^\/api\//)
} else if (typeof v === 'function') {
const sample = v(1, 2)
expect(typeof sample, here).toBe('string')
expect(sample, here).toMatch(/^\/api\//)
} else if (v && typeof v === 'object') {
walk(v, here)
} else {
throw new Error(`unexpected leaf at ${here}: ${typeof v}`)
}
}
}
walk(apiEndpoints[entity])
},
)
it('TASKS.IMAGES exposes CONTENT, CREATE, DELETE, LIST', () => {
expect(Object.keys(apiEndpoints.TASKS.IMAGES).sort()).toEqual(
['CONTENT', 'CREATE', 'DELETE', 'LIST'],
)
})
it('TASKS.IMAGES.CONTENT(taskId, imageId) produces the /content/ blob URL', () => {
expect(apiEndpoints.TASKS.IMAGES.CONTENT(7, 42)).toBe(
'/api/tasks/7/images/42/content/',
)
})
it('TASKS.IMAGES.DELETE(taskId, imageId) embeds both ids', () => {
expect(apiEndpoints.TASKS.IMAGES.DELETE(7, 42)).toBe(
'/api/tasks/7/images/42/',
)
})
})
\ No newline at end of file
// Seam tests for src/stores/auth.js — the Pinia auth store.
//
// First Pinia-using spec in the project. Mocks vue-router, authService,
// lanAccess, and apiClient. Drives the response interceptor's eight
// branches (including the LAN GET-bypass in both the retry-already-set
// path and the refresh-failed path) which are 100% uncovered elsewhere.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Shared spy for router.push — captured via vi.hoisted so it's available
// to the vi.mock factory before any imports are resolved.
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
vi.mock('@/api/authService.js', () => ({
login: vi.fn(),
refreshAccessToken: vi.fn(),
}))
vi.mock('@/utils/lanAccess.js', () => ({ isLanAccess: vi.fn(() => false) }))
// Mock apiClient with a vi.fn() so it's both callable (apiClient(config))
// and carries the method bag + interceptor slots. We capture response
// handlers by pushing into a closure array on .use().
const { apiClient, responseHandlers, requestHandlers } = vi.hoisted(() => {
const responseHandlers = []
const requestHandlers = []
const ac = vi.fn()
ac.get = vi.fn()
ac.post = vi.fn()
ac.patch = vi.fn()
ac.delete = vi.fn()
ac.interceptors = {
request: {
use: vi.fn((fulfilled) => requestHandlers.push(fulfilled)),
},
response: {
use: vi.fn((fulfilled, rejected) =>
responseHandlers.push({ fulfilled, rejected }),
),
},
}
ac.defaults = { headers: { common: {} } }
return { apiClient: ac, responseHandlers, requestHandlers }
})
vi.mock('@/api/index.js', () => ({ default: apiClient }))
// Must come after vi.mock so the auth store sees the mocks.
import { useAuthStore } from '@/stores/auth.js'
import { isLanAccess } from '@/utils/lanAccess.js'
import { login as authLogin, refreshAccessToken as authRefresh } from '@/api/authService.js'
import { makeJwt, expiredJwt, expiringSoonJwt } from '../utils/jwtFactory.js'
// ─── Setup ─────────────────────────────────────────────────────────
//
// We manipulate jsdom's real `localStorage` directly (not a vi.stubGlobal
// mock) because `stores/auth.js` reads `localStorage` at module-eval time
// in its initial `ref(...)` values. A stub installed in beforeEach is too
// late — by then the store's setup function has already snapshotted
// jsdom's localStorage. Clearing jsdom's localStorage in beforeEach
// gives us a clean slate without that trap.
//
// Vitest+jsdom also exposes `localStorage.removeItem` as a spy we can
// use for logout-side-effect assertions.
beforeEach(() => {
setActivePinia(createPinia())
window.localStorage.clear()
apiClient.defaults.headers.common = {}
responseHandlers.length = 0
requestHandlers.length = 0
apiClient.mockReset()
apiClient.mockResolvedValue({ data: 'retry-ok' })
apiClient.get.mockReset()
apiClient.post.mockReset()
apiClient.patch.mockReset()
apiClient.delete.mockReset()
routerPush.mockClear()
authLogin.mockReset()
authRefresh.mockReset()
isLanAccess.mockReturnValue(false)
})
// ─── Helpers ───────────────────────────────────────────────────────
const triggerResponseError = (error) => {
const last = responseHandlers[responseHandlers.length - 1]
return last.rejected(error)
}
const makeAxiosError = ({
status = 401,
url = '/api/students/',
method = 'get',
_retry = false,
data = { detail: 'x' },
} = {}) => ({
response: { status, data },
config: { url, method, headers: {}, _retry },
})
// ─── Token expiry (indirect decodeJwt coverage) ────────────────────
describe('token expiry (indirect decodeJwt coverage)', () => {
it('isAccessTokenExpired returns false when no access token is set', () => {
expect(useAuthStore().isAccessTokenExpired()).toBe(false)
})
it('isAccessTokenExpired returns false for a valid token (exp in future)', () => {
const auth = useAuthStore()
auth.accessToken = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 })
expect(auth.isAccessTokenExpired()).toBe(false)
})
it('isAccessTokenExpired returns true for an expired token', () => {
const auth = useAuthStore()
auth.accessToken = expiredJwt(-10)
expect(auth.isAccessTokenExpired()).toBe(true)
})
it('isAccessTokenExpired returns false when payload lacks an exp claim', () => {
const auth = useAuthStore()
// base64url encode a payload without `exp`
const payload = btoa(JSON.stringify({ sub: 'no-exp' }))
.replace(/=+$/, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
auth.accessToken = `h.${payload}.s`
expect(auth.isAccessTokenExpired()).toBe(false)
})
it('isAccessTokenExpired returns false for a garbage token (decode fails)', () => {
const auth = useAuthStore()
auth.accessToken = 'not.a.jwt'
expect(auth.isAccessTokenExpired()).toBe(false)
})
it('isAccessTokenExpiringSoon returns true within the default 60s window', () => {
const auth = useAuthStore()
auth.accessToken = expiringSoonJwt(30)
expect(auth.isAccessTokenExpiringSoon()).toBe(true)
})
it('isAccessTokenExpiringSoon respects a custom threshold', () => {
const auth = useAuthStore()
auth.accessToken = makeJwt({ exp: Math.floor(Date.now() / 1000) + 100 })
expect(auth.isAccessTokenExpiringSoon(200)).toBe(true)
expect(auth.isAccessTokenExpiringSoon(50)).toBe(false)
})
})
// ─── isAuthenticated ───────────────────────────────────────────────
describe('isAuthenticated', () => {
it('is false when accessToken is null', () => {
expect(useAuthStore().isAuthenticated).toBe(false)
})
it('is true when accessToken is set', () => {
const auth = useAuthStore()
auth.accessToken = 'any-non-empty'
expect(auth.isAuthenticated).toBe(true)
})
})
// ─── initializeAuth ────────────────────────────────────────────────
describe('initializeAuth', () => {
it('restores accessToken, refreshToken, and user from localStorage', () => {
localStorage.setItem('accessToken', 'A')
localStorage.setItem('refreshToken', 'R')
localStorage.setItem('user', JSON.stringify({ username: 'alice' }))
const auth = useAuthStore()
auth.initializeAuth()
expect(auth.accessToken).toBe('A')
expect(auth.refreshToken).toBe('R')
expect(auth.user).toEqual({ username: 'alice' })
})
it('sets the axios Authorization header from the saved token', () => {
localStorage.setItem('accessToken', 'A')
localStorage.setItem('user', JSON.stringify({ username: 'u' }))
useAuthStore().initializeAuth()
expect(apiClient.defaults.headers.common.Authorization).toBe('Bearer A')
})
it('does nothing when either accessToken or user is missing', () => {
// Only set accessToken (no user) → initializeAuth should skip the
// restore block and NOT set the axios Authorization header.
localStorage.setItem('accessToken', 'A')
const auth = useAuthStore()
auth.initializeAuth()
expect(apiClient.defaults.headers.common.Authorization).toBeUndefined()
})
it('registers the response interceptor exactly once (idempotent)', () => {
const auth = useAuthStore()
auth.initializeAuth()
auth.initializeAuth()
expect(responseHandlers).toHaveLength(1)
expect(requestHandlers).toHaveLength(1)
})
})
// ─── login ─────────────────────────────────────────────────────────
describe('login', () => {
it('success: writes access/refresh/user + header + pushes /', async () => {
authLogin.mockResolvedValueOnce({ access: 'A', refresh: 'R' })
const auth = useAuthStore()
const out = await auth.login({ username: 'alice', password: 'pw' })
expect(out).toEqual({ success: true })
expect(auth.accessToken).toBe('A')
expect(auth.refreshToken).toBe('R')
expect(auth.user).toEqual({ username: 'alice' })
expect(localStorage.getItem('accessToken')).toBe('A')
expect(localStorage.getItem('refreshToken')).toBe('R')
expect(apiClient.defaults.headers.common.Authorization).toBe('Bearer A')
expect(routerPush).toHaveBeenCalledWith('/')
})
it('success with no refresh field: refreshToken stays null, not in localStorage', async () => {
authLogin.mockResolvedValueOnce({ access: 'A' })
const auth = useAuthStore()
await auth.login({ username: 'u', password: 'p' })
expect(auth.refreshToken).toBeNull()
expect(localStorage.getItem('refreshToken')).toBeNull()
})
it('returns { success: false, error } when response has no access field', async () => {
authLogin.mockResolvedValueOnce({ refresh: 'R' }) // no access
const out = await useAuthStore().login({ username: 'u', password: 'p' })
expect(out).toEqual({ success: false, error: '未获取到访问令牌' })
expect(routerPush).not.toHaveBeenCalled()
})
it('returns { success: false, error: detail } on 401', async () => {
authLogin.mockRejectedValueOnce(
Object.assign(new Error('e'), {
response: { data: { detail: 'Invalid creds' } },
}),
)
const out = await useAuthStore().login({ username: 'u', password: 'bad' })
expect(out).toEqual({ success: false, error: 'Invalid creds' })
expect(routerPush).not.toHaveBeenCalled()
})
it('falls back to error.message when no response detail is present', async () => {
authLogin.mockRejectedValueOnce(new Error('boom'))
const out = await useAuthStore().login({ username: 'u', password: 'p' })
expect(out).toEqual({ success: false, error: 'boom' })
})
})
// ─── logout ────────────────────────────────────────────────────────
describe('logout', () => {
it('clears state, localStorage, axios header, and pushes /login', () => {
const auth = useAuthStore()
auth.accessToken = 'A'
auth.refreshToken = 'R'
auth.user = { username: 'u' }
apiClient.defaults.headers.common.Authorization = 'Bearer A'
auth.logout()
expect(auth.accessToken).toBeNull()
expect(auth.refreshToken).toBeNull()
expect(auth.user).toBeNull()
expect(localStorage.getItem('accessToken')).toBeNull()
expect(localStorage.getItem('refreshToken')).toBeNull()
expect(localStorage.getItem('user')).toBeNull()
expect(apiClient.defaults.headers.common.Authorization).toBeUndefined()
expect(routerPush).toHaveBeenCalledWith('/login')
})
it('is safe to call when nothing is set', () => {
expect(() => useAuthStore().logout()).not.toThrow()
expect(routerPush).toHaveBeenCalledWith('/login')
})
})
// ─── refreshAccessToken ────────────────────────────────────────────
describe('refreshAccessToken', () => {
it('throws NO_REFRESH_TOKEN when no refresh token is set', async () => {
await expect(useAuthStore().refreshAccessToken()).rejects.toThrow(
'NO_REFRESH_TOKEN',
)
})
it('updates accessToken, localStorage, and axios header on success', async () => {
authRefresh.mockResolvedValueOnce({ access: 'NEW' })
const auth = useAuthStore()
auth.refreshToken = 'OLD'
const out = await auth.refreshAccessToken()
expect(out).toBe('NEW')
expect(auth.accessToken).toBe('NEW')
expect(localStorage.getItem('accessToken')).toBe('NEW')
expect(apiClient.defaults.headers.common.Authorization).toBe('Bearer NEW')
})
it('throws NO_ACCESS_FROM_REFRESH when response lacks an access field', async () => {
authRefresh.mockResolvedValueOnce({ foo: 'bar' })
const auth = useAuthStore()
auth.refreshToken = 'OLD'
await expect(auth.refreshAccessToken()).rejects.toThrow(
'NO_ACCESS_FROM_REFRESH',
)
})
it('dedupes concurrent calls — authRefresh invoked only once', async () => {
let resolveRefresh
authRefresh.mockReturnValueOnce(
new Promise((r) => {
resolveRefresh = r
}),
)
const auth = useAuthStore()
auth.refreshToken = 'OLD'
const p1 = auth.refreshAccessToken()
const p2 = auth.refreshAccessToken()
const p3 = auth.refreshAccessToken()
expect(authRefresh).toHaveBeenCalledTimes(1)
resolveRefresh({ access: 'NEW' })
await Promise.all([p1, p2, p3])
expect(auth.accessToken).toBe('NEW')
})
})
// ─── Axios response interceptor — 401 branches ─────────────────────
describe('axios response interceptor — 401 branches', () => {
// Initialize the store so setupAxiosInterceptors runs and pushes the
// rejected handler into responseHandlers.
const init = () => useAuthStore().initializeAuth()
it('[1] non-401 error: passes through, no refresh, no logout', async () => {
init()
const auth = useAuthStore()
auth.accessToken = 'TOK'
const err = makeAxiosError({ status: 500 })
await expect(triggerResponseError(err)).rejects.toBe(err)
expect(authRefresh).not.toHaveBeenCalled()
expect(auth.accessToken).toBe('TOK')
})
it('[2] 401 on /api/token/ (login): passes through, no refresh', async () => {
init()
const err = makeAxiosError({ url: '/api/token/', method: 'post' })
await expect(triggerResponseError(err)).rejects.toBe(err)
expect(authRefresh).not.toHaveBeenCalled()
})
it('[3] 401 first time: refreshes, retries via apiClient', async () => {
init()
authRefresh.mockResolvedValueOnce({ access: 'NEW' })
const auth = useAuthStore()
auth.refreshToken = 'OLD'
const err = makeAxiosError()
await triggerResponseError(err)
expect(authRefresh).toHaveBeenCalledTimes(1)
expect(err.config._retry).toBe(true)
expect(apiClient).toHaveBeenCalledWith(err.config)
})
it('[4] 401 already retried + LAN + GET: rejects without logout', async () => {
init()
isLanAccess.mockReturnValue(true)
const auth = useAuthStore()
auth.accessToken = 'TOK'
localStorage.setItem('accessToken', 'TOK')
const err = makeAxiosError({ method: 'get', _retry: true })
await expect(triggerResponseError(err)).rejects.toBe(err)
// logout() did NOT run — token remains in state and storage
expect(auth.accessToken).toBe('TOK')
expect(localStorage.getItem('accessToken')).toBe('TOK')
})
it('[5] 401 already retried + NOT(LAN+GET): calls logout and rejects', async () => {
init()
isLanAccess.mockReturnValue(false)
const auth = useAuthStore()
auth.accessToken = 'TOK'
localStorage.setItem('accessToken', 'TOK')
const err = makeAxiosError({ method: 'post', _retry: true })
await expect(triggerResponseError(err)).rejects.toBe(err)
// logout() ran via interceptor — side effect: token cleared
expect(auth.accessToken).toBeNull()
expect(auth.user).toBeNull()
expect(auth.refreshToken).toBeNull()
expect(localStorage.getItem('accessToken')).toBeNull()
})
it('[6] 401 first time + refresh fails + LAN + GET: rejects original error, no logout', async () => {
init()
isLanAccess.mockReturnValue(true)
authRefresh.mockReset()
authRefresh.mockRejectedValueOnce(new Error('refresh-fail'))
const auth = useAuthStore()
auth.refreshToken = 'OLD'
auth.accessToken = 'TOK'
localStorage.setItem('accessToken', 'TOK')
const err = makeAxiosError({ method: 'get' })
await expect(triggerResponseError(err)).rejects.toBe(err)
expect(auth.accessToken).toBe('TOK')
expect(auth.user).toBeNull() // already null, no logout side effect
expect(localStorage.getItem('accessToken')).toBe('TOK')
})
it('[7] 401 first time + refresh fails + NOT(LAN+GET): calls logout, rejects refresh error', async () => {
init()
isLanAccess.mockReturnValue(false)
const refreshErr = new Error('refresh-fail')
authRefresh.mockReset()
authRefresh.mockRejectedValueOnce(refreshErr)
const auth = useAuthStore()
auth.refreshToken = 'OLD'
auth.accessToken = 'TOK'
localStorage.setItem('accessToken', 'TOK')
const err = makeAxiosError({ method: 'post' })
await expect(triggerResponseError(err)).rejects.toBe(refreshErr)
expect(auth.accessToken).toBeNull()
expect(auth.user).toBeNull()
expect(auth.refreshToken).toBeNull()
expect(localStorage.getItem('accessToken')).toBeNull()
})
})
// ─── Request interceptor ───────────────────────────────────────────
describe('axios request interceptor', () => {
it('attaches Authorization: Bearer <token> when accessToken is set', () => {
const auth = useAuthStore()
auth.accessToken = 'TOK'
auth.initializeAuth()
const lastHandler = requestHandlers[requestHandlers.length - 1]
const config = { headers: {} }
const out = lastHandler(config)
expect(out.headers.Authorization).toBe('Bearer TOK')
})
it('leaves config.headers.Authorization unset when no token', () => {
const auth = useAuthStore()
auth.initializeAuth()
const lastHandler = requestHandlers[requestHandlers.length - 1]
const config = { headers: {} }
const out = lastHandler(config)
expect(out.headers.Authorization).toBeUndefined()
})
})
\ No newline at end of file
// 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'])
})
})
\ No newline at end of file
// JWT factory for seam tests. Hand-rolls an HS256-shaped token with a
// base64url-encoded payload that matches the shape decodeJwt() in
// stores/auth.js expects. jsdom provides btoa/atob natively.
//
// Usage:
// const tok = makeJwt() // valid 1h token
// const exp = expiredJwt() // exp 10s ago
// const soon = expiringSoonJwt(30) // exp 30s from now
const now = () => Math.floor(Date.now() / 1000)
const base64UrlEncode = (str) =>
btoa(str).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_')
export const makeJwt = (claims = {}) => {
const payload = {
iat: now(),
sub: 'test-user',
exp: now() + 3600,
...claims,
}
const header = base64UrlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = base64UrlEncode(JSON.stringify(payload))
return `${header}.${body}.sig`
}
export const expiredJwt = (offsetSeconds = -10) =>
makeJwt({ exp: now() + offsetSeconds })
export const expiringSoonJwt = (thresholdSeconds = 30) =>
makeJwt({ exp: now() + thresholdSeconds })
\ No newline at end of file
// Real isLanAccess() — exercises the CIDR parsing in src/utils/lanAccess.js
// that authGuard.spec.js mocks out. No top-level vi.mock; we control the
// hostname by redefine window.location per test.
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { isLanAccess } from '@/utils/lanAccess.js'
const setHostname = (hostname) => {
Object.defineProperty(window, 'location', {
value: { hostname },
writable: true,
configurable: true,
})
}
describe('isLanAccess — host bypass', () => {
beforeEach(() => setHostname('localhost'))
it('returns true for localhost', () => {
setHostname('localhost')
expect(window.location.hostname).toBe('localhost')
expect(isLanAccess()).toBe(true)
})
it('returns true for 127.0.0.1', () => {
setHostname('127.0.0.1')
expect(isLanAccess()).toBe(true)
})
})
describe('isLanAccess — CIDR allow (192.168.1.0/24)', () => {
beforeEach(() => setHostname('192.168.1.50'))
it.each([
['192.168.1.0', 'network address (boundary)'],
['192.168.1.1', 'gateway'],
['192.168.1.50', 'mid-range'],
['192.168.1.255', 'broadcast (boundary)'],
])('returns true for %s (%s)', (ip) => {
setHostname(ip)
expect(window.location.hostname).toBe(ip)
expect(isLanAccess()).toBe(true)
})
})
describe('isLanAccess — CIDR allow (192.168.2.0/24)', () => {
beforeEach(() => setHostname('192.168.2.100'))
it.each([
['192.168.2.0'],
['192.168.2.100'],
['192.168.2.255'],
])('returns true for %s', (ip) => {
setHostname(ip)
expect(isLanAccess()).toBe(true)
})
})
describe('isLanAccess — deny', () => {
beforeEach(() => setHostname('8.8.8.8'))
it.each([
['192.168.3.1', 'adjacent subnet (CIDR out)'],
['192.168.0.1', '192.168.0.x (not in /24s)'],
['10.0.0.1', 'private 10.x (not whitelisted)'],
['172.16.0.1', 'private 172.16.x (not whitelisted)'],
['8.8.8.8', 'public DNS'],
['0.0.0.0', 'unspecified address'],
['', 'empty string'],
['example.com', 'hostname (not IP)'],
])('returns false for %s (%s)', (ip) => {
setHostname(ip)
expect(isLanAccess()).toBe(false)
})
})
afterEach(() => setHostname('localhost'))
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment