import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { createMemoryHistory } from 'vue-router' import App from '../../src/App.vue' import LoginView from '../../src/views/LoginView.vue' import { createAdminRouter, safeRedirect } from '../../src/app/router' import { runtime } from '../../src/app/runtime' import { api, onUnauthorized } from '../../src/api/client' import { clearUser, me, useAuth } from '../../src/state/useAuth' import { loadNavigation, setNavigation } from '../../src/state/useNavigation' import navigation from '../fixtures/navigation.json' import listSchema from '../fixtures/widgets.list-schema.json' import listRows from '../fixtures/widgets.list.json' const API = '/admin-test/api/v1' const profile = { data: { id: 7, login: 'dev', email: 'dev@example.test', first_name: 'Dana', last_name: 'Dev', is_superuser: false, role: { id: 2, code: 'developer', name: 'Developer' }, }, meta: {}, } type Routes = Record function mockApi(routes: Routes): Request[] { const calls: Request[] = [] vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { const request = input as Request calls.push(request) const key = `${request.method} ${new URL(request.url).pathname}` const route = routes[key] const status = route ? (route.status ?? 200) : 404 const body = route ? route.body : { error: { code: 'not_found', message: 'Not found', details: {} } } return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) }) return calls } function pathOf(request: Request): string { return new URL(request.url).pathname } beforeEach(() => { clearUser() setNavigation([]) onUnauthorized(null) }) afterEach(() => { localStorage.clear() sessionStorage.clear() }) describe('runtime', () => { it('reads the admin base and API base from the served meta', () => { expect(runtime.base).toBe('/admin-test') expect(runtime.api).toBe(API) }) }) describe('login', () => { it('posts the CSRF header with same-origin credentials and stores no token', async () => { const calls = mockApi({ [`POST ${API}/auth/login`]: { body: { data: { token_type: 'cookie', expires_in: 3600, access_token: 'must-not-be-kept' }, meta: {} }, }, }) const auth = useAuth() expect(await auth.login('dev', 'secret')).toBe(true) expect(calls).toHaveLength(1) const request = calls[0]! expect(request.method).toBe('POST') expect(pathOf(request)).toBe(`${API}/auth/login`) expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest') expect(request.credentials).toBe('same-origin') expect(await request.json()).toEqual({ login: 'dev', password: 'secret' }) expect(auth.expiresIn.value).toBe(3600) expect(localStorage.length).toBe(0) expect(sessionStorage.length).toBe(0) expect(document.cookie).not.toContain('must-not-be-kept') expect(JSON.stringify({ user: auth.user.value, expiresIn: auth.expiresIn.value })).not.toContain('must-not-be-kept') }) it('shows the invalid-credentials alert and marks the password invalid', async () => { mockApi({ [`POST ${API}/auth/login`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'Invalid credentials', details: {} } }, }, }) const router = createAdminRouter(createMemoryHistory()) await router.push('/login') const wrapper = mount(LoginView, { global: { plugins: [router] } }) await wrapper.find('input[name="login"]').setValue('dev') await wrapper.find('input[name="password"]').setValue('wrong') await wrapper.find('form').trigger('submit') await flushPromises() expect(wrapper.find('[role="alert"]').exists()).toBe(true) expect(wrapper.find('input[name="password"]').attributes('aria-invalid')).toBe('true') expect(router.currentRoute.value.name).toBe('login') }) }) describe('redirects', () => { it('accepts only in-app paths that start with exactly one slash', () => { expect(safeRedirect('/acme/demo/widgets?page=2')).toBe('/acme/demo/widgets?page=2') expect(safeRedirect('https://evil.example/steal')).toBeNull() expect(safeRedirect('//evil.example/steal')).toBeNull() expect(safeRedirect('/\\evil.example')).toBeNull() expect(safeRedirect('acme/demo')).toBeNull() expect(safeRedirect(['/acme'])).toBeNull() }) it('sends an unauthenticated visitor to login with the requested path', async () => { const router = createAdminRouter(createMemoryHistory()) await router.push('/acme/demo/widgets?page=2') expect(router.currentRoute.value.name).toBe('login') expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets?page=2') }) it('reports a 401 on any call other than login', async () => { mockApi({ [`GET ${API}/auth/me`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'Unauthenticated', details: {} } } }, [`POST ${API}/auth/login`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'Invalid credentials', details: {} } } }, }) const handler = vi.fn() onUnauthorized(handler) await useAuth().login('dev', 'wrong') expect(handler).not.toHaveBeenCalled() await me() expect(handler).toHaveBeenCalledTimes(1) }) }) describe('navigation shell and list', () => { async function mountApp() { const calls = mockApi({ [`GET ${API}/auth/me`]: { body: profile }, [`GET ${API}/navigation`]: { body: navigation }, [`GET ${API}/acme/demo/widgets/schema/list`]: { body: listSchema }, [`GET ${API}/acme/demo/widgets`]: { body: listRows }, }) await me() await loadNavigation() const router = createAdminRouter(createMemoryHistory()) await router.push('/acme/demo/widgets') const wrapper = mount(App, { global: { plugins: [router] } }) await flushPromises() return { wrapper, calls, router } } it('renders navigation grouped by plugin and omits a plugin with an empty side menu', async () => { const { wrapper } = await mountApp() const rail = wrapper.find('nav[aria-label="backend::lang.nav.plugins"]') expect(rail.exists()).toBe(true) const railLinks = rail.findAll('a') expect(railLinks.map((link) => link.text())).toEqual(['Demo', 'Tools']) expect(railLinks[0]!.attributes('aria-current')).toBe('page') expect(railLinks[1]!.attributes('aria-current')).toBeUndefined() expect(railLinks[1]!.attributes('href')).toBe('/acme/tools/hammers') // An unknown icon name renders the neutral fallback instead of failing. expect(railLinks[1]!.find('svg').exists()).toBe(true) const panel = wrapper.find('aside') const panelLinks = panel.findAll('a') expect(panel.text()).toContain('Demo') expect(panelLinks.map((link) => link.text())).toEqual(['Widgets', 'Gadgets']) expect(panelLinks[0]!.attributes('aria-current')).toBe('page') expect(panelLinks[1]!.attributes('href')).toBe('/acme/demo/gadgets') }) it('renders the list schema columns and rows through the typed client', async () => { const { wrapper, calls } = await mountApp() const requested = calls.map(pathOf) expect(requested).toContain(`${API}/acme/demo/widgets/schema/list`) expect(requested).toContain(`${API}/acme/demo/widgets`) for (const request of calls) { expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest') } expect(wrapper.find('h1').text()).toBe('Widgets') expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags']) const rows = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text())) expect(rows).toEqual([ ['Blue widget', 'W-01', 'small, round'], ['Green widget', 'W-02', '—'], ]) }) }) describe('session refresh', () => { function json(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) } const unauthenticated = { error: { code: 'unauthenticated', message: 'Unauthenticated', details: {} } } it('single-flights one refresh for concurrent 401s and replays each request once', async () => { let refreshed = false const seen: Request[] = [] vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { const request = input as Request seen.push(request) const path = pathOf(request) if (path === `${API}/auth/refresh`) { await new Promise((resolve) => setTimeout(resolve, 5)) refreshed = true return json(200, { data: { token_type: 'cookie', expires_in: 3600 }, meta: {} }) } if (!refreshed) { return json(401, unauthenticated) } if (path === `${API}/navigation`) { return json(200, navigation) } return json(200, profile) }) const handler = vi.fn() onUnauthorized(handler) const [nav, who] = await Promise.all([api.GET('/navigation'), api.GET('/auth/me')]) expect(nav.response.status).toBe(200) expect(who.response.status).toBe(200) expect(nav.data?.data).toHaveLength(3) expect(who.data?.data.login).toBe('dev') const refreshes = seen.filter((request) => pathOf(request) === `${API}/auth/refresh`) expect(refreshes).toHaveLength(1) expect(refreshes[0]!.method).toBe('POST') expect(refreshes[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest') expect(seen.filter((request) => pathOf(request) === `${API}/navigation`)).toHaveLength(2) expect(seen.filter((request) => pathOf(request) === `${API}/auth/me`)).toHaveLength(2) expect(handler).not.toHaveBeenCalled() }) it('goes to login when the refresh fails and never replays twice', async () => { const seen: Request[] = [] vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { const request = input as Request seen.push(request) return json(401, unauthenticated) }) const handler = vi.fn() onUnauthorized(handler) const result = await api.GET('/navigation') expect(result.response.status).toBe(401) expect(seen.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(1) expect(seen.filter((request) => pathOf(request) === `${API}/navigation`)).toHaveLength(1) expect(handler).toHaveBeenCalledTimes(1) }) it('goes to login when the replay is still unauthorized', async () => { const seen: Request[] = [] vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => { const request = input as Request seen.push(request) if (pathOf(request) === `${API}/auth/refresh`) { return json(200, { data: { token_type: 'cookie', expires_in: 3600 }, meta: {} }) } return json(401, unauthenticated) }) const handler = vi.fn() onUnauthorized(handler) const result = await api.GET('/navigation') expect(result.response.status).toBe(401) expect(seen.filter((request) => pathOf(request) === `${API}/navigation`)).toHaveLength(2) expect(handler).toHaveBeenCalledTimes(1) }) it('schedules a proactive refresh at 80 percent of expires_in', async () => { vi.useFakeTimers() try { const calls = mockApi({ [`POST ${API}/auth/login`]: { body: { data: { token_type: 'cookie', expires_in: 100 }, meta: {} } }, [`POST ${API}/auth/refresh`]: { body: { data: { token_type: 'cookie', expires_in: 100 }, meta: {} } }, }) expect(await useAuth().login('dev', 'secret')).toBe(true) await vi.advanceTimersByTimeAsync(79_000) expect(calls.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(0) await vi.advanceTimersByTimeAsync(2_000) expect(calls.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(1) clearUser() await vi.advanceTimersByTimeAsync(200_000) expect(calls.filter((request) => pathOf(request) === `${API}/auth/refresh`)).toHaveLength(1) } finally { vi.useRealTimers() } }) })