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 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[0][string] | 'throw') { const routes: Parameters[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[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') }) })