Files
summercms/admin/tests/state/useAuth.test.ts
Jakub Zych a8bce9c01f test(admin): list state round trip and storage coverage
- Form back, cancel, save-and-close and delete land on the list with the stored query; every list request after the return carries it
- listState helpers: key prefix, malformed stored values removed, empty query stores nothing, throwing storage
- ListView entry: URL wins, cleared controls forget the query, same-list navigation, 422 forgets, page clamp
- Logout removes every list key and keeps the sidebar preference
2026-10-09 22:42:36 +02:00

192 lines
7.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearUser,
currentUser,
login,
logout,
me,
profileInitials,
setCurrentUser,
useAuth,
} from '../../src/state/useAuth'
import { writeListState } from '../../src/state/listState'
import { navigation, setNavigation } from '../../src/state/useNavigation'
import { SIDEBAR_KEY } from '../../src/state/useSidebar'
import { setSettings, settings } from '../../src/state/useSettings'
import { navigationFixture, settingsFixture } from '../fixtures/typed'
import { API, mockApi, profile, requestsTo, resetState } from '../helpers'
const loginOk = (expiresIn: unknown) => ({ body: { data: { token_type: 'cookie', expires_in: expiresIn }, meta: {} } })
const refreshOk = { body: { data: { token_type: 'cookie', expires_in: 50 }, meta: {} } }
const unauthorized = { status: 401, body: { error: { code: 'unauthenticated', message: 'no', details: {} } } }
beforeEach(() => {
resetState()
})
afterEach(() => {
clearUser()
vi.useRealTimers()
})
describe('login', () => {
it('posts the credentials and schedules the proactive refresh at 80 percent', async () => {
vi.useFakeTimers()
const calls = mockApi({ [`POST ${API}/auth/login`]: loginOk(100), [`POST ${API}/auth/refresh`]: refreshOk })
expect(await login('dev', 'secret')).toBe(true)
const body = (await requestsTo(calls, 'POST', `${API}/auth/login`)[0]!.clone().json()) as Record<string, string>
expect(body).toEqual({ login: 'dev', password: 'secret' })
expect(useAuth().expiresIn.value).toBe(100)
await vi.advanceTimersByTimeAsync(79_999)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
await vi.advanceTimersByTimeAsync(1)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(1)
// The refresh reschedules from its own lifetime (50 s -> 40 s).
expect(useAuth().expiresIn.value).toBe(50)
await vi.advanceTimersByTimeAsync(40_000)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(2)
})
it('answers false on rejected credentials and schedules nothing', async () => {
vi.useFakeTimers()
const calls = mockApi({ [`POST ${API}/auth/login`]: unauthorized, [`POST ${API}/auth/refresh`]: refreshOk })
expect(await login('dev', 'bad')).toBe(false)
await vi.advanceTimersByTimeAsync(10_000_000)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
})
it.each([0, -5, 'soon', null])('schedules no refresh for the lifetime %j', async (expiresIn) => {
vi.useFakeTimers()
const calls = mockApi({ [`POST ${API}/auth/login`]: loginOk(expiresIn), [`POST ${API}/auth/refresh`]: refreshOk })
expect(await login('dev', 'secret')).toBe(true)
await vi.advanceTimersByTimeAsync(10_000_000)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
})
})
describe('me and clearUser', () => {
it('loads the profile, and clears it on 401', async () => {
mockApi({ [`GET ${API}/auth/me`]: { body: profile } })
expect((await me())?.login).toBe('dev')
expect(currentUser.value?.role?.name).toBe('Developer')
mockApi({ [`GET ${API}/auth/me`]: unauthorized })
expect(await me()).toBeNull()
expect(currentUser.value).toBeNull()
})
it('clearUser forgets the user and cancels the pending refresh', async () => {
vi.useFakeTimers()
const calls = mockApi({
[`GET ${API}/auth/me`]: { body: profile },
[`POST ${API}/auth/login`]: loginOk(10),
[`POST ${API}/auth/refresh`]: refreshOk,
})
await login('dev', 'secret')
await me()
clearUser()
expect(currentUser.value).toBeNull()
expect(useAuth().expiresIn.value).toBeNull()
await vi.advanceTimersByTimeAsync(60_000)
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(0)
})
})
describe('logout (T-10-23)', () => {
async function signedIn(logoutRoute: Parameters<typeof mockApi>[0][string] | 'throw') {
const routes: Parameters<typeof mockApi>[0] = { [`GET ${API}/auth/me`]: { body: profile } }
if (logoutRoute !== 'throw') {
routes[`POST ${API}/auth/logout`] = logoutRoute
}
const calls = mockApi(routes)
await me()
setNavigation(navigationFixture.data)
setSettings(settingsFixture.list.data)
if (logoutRoute === 'throw') {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'))
}
return calls
}
it.each([
['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }],
['answers 500', { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } }],
['fails on the network', 'throw' as const],
])('clears the user, navigation and settings and routes to login when the call %s', async (_label, route) => {
const calls = await signedIn(route)
const router = { replace: vi.fn().mockResolvedValue(undefined) }
await logout(router)
expect(currentUser.value).toBeNull()
expect(navigation.value).toEqual([])
expect(settings.value).toEqual([])
expect(router.replace).toHaveBeenCalledWith({ name: 'login' })
if (route !== 'throw') {
const posted = requestsTo(calls, 'POST', `${API}/auth/logout`)
expect(posted).toHaveLength(1)
expect(posted[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
}
})
it.each([
['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }],
['answers 500', { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } }],
['fails on the network', 'throw' as const],
])('removes every stored list query and keeps the sidebar preference when the call %s', async (_label, route) => {
await signedIn(route)
window.localStorage.setItem(SIDEBAR_KEY, 'true')
writeListState('acme.demo.widgets', { search: 'blue', sort: 'code', dir: 'desc' })
writeListState('acme.demo.gadgets', { filter: { active: 'true' } })
expect(Object.keys(window.localStorage).filter((key) => key.startsWith('summer-admin.list:'))).toHaveLength(2)
await logout({ replace: vi.fn().mockResolvedValue(undefined) })
expect(Object.keys(window.localStorage).filter((key) => key.startsWith('summer-admin.list:'))).toEqual([])
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
})
it('works without a router and swallows a failed navigation', async () => {
await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } })
await expect(logout()).resolves.toBeUndefined()
expect(currentUser.value).toBeNull()
await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } })
await expect(logout({ replace: vi.fn().mockRejectedValue(new Error('aborted')) })).resolves.toBeUndefined()
expect(currentUser.value).toBeNull()
})
it('exposes the session API through useAuth', () => {
const auth = useAuth()
expect(auth.user).toBe(currentUser)
expect(auth.login).toBe(login)
expect(auth.logout).toBe(logout)
expect(auth.me).toBe(me)
expect(auth.clearUser).toBe(clearUser)
})
})
describe('setCurrentUser and profileInitials', () => {
it('replaces the signed-in profile, as My account does after a save', async () => {
mockApi({ [`GET ${API}/auth/me`]: { body: profile } })
await me()
const next = { ...profile.data, first_name: 'Grace', subtitle: 'Admiral', avatar: { url: '/a.png' } }
setCurrentUser(next)
expect(currentUser.value).toEqual(next)
expect(useAuth().user.value?.subtitle).toBe('Admiral')
expect(useAuth().setCurrentUser).toBe(setCurrentUser)
clearUser()
expect(currentUser.value).toBeNull()
})
it('takes the name initials, else the login letters', () => {
expect(profileInitials(null)).toBe('')
expect(profileInitials({ first_name: 'grace', last_name: 'hopper', login: 'gh' })).toBe('GH')
expect(profileInitials({ first_name: ' ', last_name: 'Hopper', login: 'gh' })).toBe('H')
expect(profileInitials({ first_name: '', last_name: '', login: 'root' })).toBe('RO')
})
})