- Store list queries under summer-admin.list:<admin id>:<controller>; me() and setCurrentUser set the owner, which removes other admins' keys and the unscoped keys of older builds; nothing is read or written without one - clearUser removes every stored query, so logout, session expiry and an unrecoverable 401 (expireSession, now used by main.ts) all clear them - A list request built from a restored query that fails for any reason (422, 500, network) forgets the query and loads the plain list once - A failing URL query keeps the error and offers a reset button (backend::lang.list.reset_list, en and pl) - Document remembered list state in docs/backend/lists-and-filters.md - Rebuild modules/boardwalk/dist
319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createMemoryHistory } from 'vue-router'
|
|
import { api, onUnauthorized } from '../../src/api/client'
|
|
import { createAdminRouter } from '../../src/app/router'
|
|
import {
|
|
clearUser,
|
|
currentUser,
|
|
expireSession,
|
|
login,
|
|
logout,
|
|
me,
|
|
profileInitials,
|
|
setCurrentUser,
|
|
useAuth,
|
|
} from '../../src/state/useAuth'
|
|
import { LIST_STATE_PREFIX, listStateOwner, readListState, 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)
|
|
expect(auth.expireSession).toBe(expireSession)
|
|
})
|
|
})
|
|
|
|
describe('stored list queries follow the session (WR-03, T-16.1-03)', () => {
|
|
const ID = 'acme.demo.widgets'
|
|
const other = { ...profile, data: { ...profile.data, id: 8, login: 'other' } }
|
|
const listKeys = () => Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))
|
|
|
|
async function signInAs(envelope: typeof profile, routes: Parameters<typeof mockApi>[0] = {}) {
|
|
const calls = mockApi({ [`GET ${API}/auth/me`]: { body: envelope }, ...routes })
|
|
await me()
|
|
return calls
|
|
}
|
|
|
|
afterEach(() => {
|
|
onUnauthorized(null)
|
|
})
|
|
|
|
it('scopes the stored queries to the admin me() loads', async () => {
|
|
await signInAs(profile)
|
|
expect(listStateOwner()).toBe('7')
|
|
writeListState(ID, { search: 'blue' })
|
|
expect(listKeys()).toEqual([`${LIST_STATE_PREFIX}7:${ID}`])
|
|
expect(readListState(ID)).toEqual({ search: 'blue' })
|
|
})
|
|
|
|
it("never hands one admin's query to the next admin who signs in on this browser", async () => {
|
|
await signInAs(profile)
|
|
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
|
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'asc' })
|
|
|
|
// The tab was closed without a logout; another admin signs in.
|
|
await signInAs(other)
|
|
expect(listStateOwner()).toBe('8')
|
|
expect(readListState(ID)).toEqual({})
|
|
expect(listKeys()).toEqual([])
|
|
})
|
|
|
|
it('keeps the own queries when the same admin loads again or edits the profile', async () => {
|
|
await signInAs(profile)
|
|
writeListState(ID, { search: 'blue' })
|
|
await signInAs(profile)
|
|
setCurrentUser({ ...profile.data, first_name: 'Grace' })
|
|
expect(readListState(ID)).toEqual({ search: 'blue' })
|
|
})
|
|
|
|
it('removes every stored query when me() finds no session', async () => {
|
|
await signInAs(profile)
|
|
writeListState(ID, { search: 'blue' })
|
|
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
|
mockApi({ [`GET ${API}/auth/me`]: unauthorized })
|
|
expect(await me()).toBeNull()
|
|
expect(listStateOwner()).toBeNull()
|
|
expect(listKeys()).toEqual([])
|
|
})
|
|
|
|
it('sweeps unscoped keys of older builds when the profile loads', async () => {
|
|
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
|
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
|
await signInAs(profile)
|
|
expect(readListState(ID)).toEqual({})
|
|
expect(listKeys()).toEqual([])
|
|
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
|
})
|
|
|
|
it('clearUser removes every stored query and stops storing', async () => {
|
|
await signInAs(profile)
|
|
writeListState(ID, { search: 'blue' })
|
|
clearUser()
|
|
expect(listKeys()).toEqual([])
|
|
writeListState(ID, { search: 'blue' })
|
|
expect(listKeys()).toEqual([])
|
|
})
|
|
|
|
it('expireSession clears the session and the stored queries and routes to login with the redirect', async () => {
|
|
await signInAs(profile)
|
|
writeListState(ID, { search: 'blue' })
|
|
const push = vi.fn().mockResolvedValue(undefined)
|
|
expireSession({ currentRoute: { value: { name: 'list', fullPath: '/acme/demo/widgets?search=blue' } }, push })
|
|
expect(currentUser.value).toBeNull()
|
|
expect(listKeys()).toEqual([])
|
|
expect(push).toHaveBeenCalledWith({ name: 'login', query: { redirect: '/acme/demo/widgets?search=blue' } })
|
|
})
|
|
|
|
it('expireSession stays on the login screen and swallows a failed navigation', async () => {
|
|
await signInAs(profile)
|
|
writeListState(ID, { search: 'blue' })
|
|
const push = vi.fn().mockResolvedValue(undefined)
|
|
expireSession({ currentRoute: { value: { name: 'login', fullPath: '/login' } }, push })
|
|
expect(push).not.toHaveBeenCalled()
|
|
expect(listKeys()).toEqual([])
|
|
|
|
await signInAs(profile)
|
|
const failing = vi.fn().mockRejectedValue(new Error('aborted'))
|
|
expireSession({ currentRoute: { value: { name: 'list', fullPath: '/acme/demo/widgets' } }, push: failing })
|
|
await Promise.resolve()
|
|
expect(failing).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('a 401 the refresh cannot recover clears the stored queries through the onUnauthorized handler of main.ts', async () => {
|
|
const calls = await signInAs(profile, {
|
|
[`GET ${API}/acme/demo/widgets`]: unauthorized,
|
|
[`POST ${API}/auth/refresh`]: unauthorized,
|
|
})
|
|
const router = createAdminRouter(createMemoryHistory())
|
|
await router.push('/acme/demo/widgets?search=blue')
|
|
await router.isReady()
|
|
// The same wiring as main.ts.
|
|
onUnauthorized(() => expireSession(router))
|
|
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
|
expect(listKeys()).toHaveLength(1)
|
|
|
|
await api.GET('/{vendor}/{plugin}/{controller}', {
|
|
params: { path: { vendor: 'acme', plugin: 'demo', controller: 'widgets' }, query: {} },
|
|
})
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(1)
|
|
expect(currentUser.value).toBeNull()
|
|
expect(listKeys()).toEqual([])
|
|
expect(router.currentRoute.value.name).toBe('login')
|
|
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets?search=blue')
|
|
})
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|