- List state (search, sort, dir, page, per_page, filter[<name>]) lives in
the URL query through parseListQuery/toListQuery; every change is a
router replace and clears the selection; search is debounced 300 ms and
resets the page
- DataTable renders the schema columns with a tri-state page checkbox,
asc/desc/none sorting with aria-sort, selected rows, a sticky header,
eight skeleton rows while loading and an empty slot
- CellValue renders text (muted dash when empty, arrays comma-joined),
datetime as YYYY-MM-DD HH:mm and switch as the Tak/Nie pills
- The heading shows the plural recordCount and the create button;
delete sits in the toolbar, disabled without a selection, and confirms
with the plural deleteConfirm in a Reka alert dialog before POSTing
bulk-delete; a 409 shows a danger toast
- FilterBar renders switch (JSON of the option value), daterange
(from..to) and scope filters (choices from filters/{scope}/options)
- Pagination shows the range, the per-page select over perPageOptions
(hidden with one choice) and a pager with ellipsis
- The tracer smoke test skips the new checkbox column; new backend::lang
list keys; boardwalk/dist rebuilt
306 lines
12 KiB
TypeScript
306 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')
|
|
// The checkbox column (data-select) is not a schema column.
|
|
expect(wrapper.findAll('th:not([data-select])').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags', 'Created', 'Active'])
|
|
const rows = wrapper
|
|
.findAll('tbody tr')
|
|
.map((tr) => tr.findAll('td:not([data-select])').map((td) => td.text()).slice(0, 3))
|
|
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()
|
|
}
|
|
})
|
|
})
|