Files
summercms/admin/tests/smoke/tracer.smoke.test.ts
Jakub Zych dafdb18234 feat(10-01): harden the admin cookie session and prefix boot guards
- refresh and logout read the Bearer header first, then the summer_admin
  cookie; a cookie refresh rotates the cookie without a token in the body and
  logout always expires the cookie
- backend.cookie_secure (default true) may drop Secure outside production only
- activation rejects controller vendor segments api, assets, login, settings
- BuildRouter rejects non-cabana routes at or under the admin prefix
- SPA single-flights refresh on 401, replays once, and refreshes proactively
  at 80 percent of expires_in; dist rebuilt
- scripts/check-admin-dist.sh rebuilds the SPA and fails on dist drift
- tests: TestPhase10CookieAuth, TestPhase10CSRF, TestPhase10Prefix,
  TestPhase10AdminPrefixCollision, boardwalk serving and header tests
2026-09-27 15:34:19 +02:00

303 lines
12 KiB
TypeScript

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<string, { status?: number; body: unknown }>
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()
}
})
})