test(10-05): bring every SPA module, composable and component under Vitest
- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
This commit is contained in:
161
admin/tests/app/client.test.ts
Normal file
161
admin/tests/app/client.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api, onRefreshed, onUnauthorized, refreshSession, REQUESTED_WITH } from '../../src/api/client'
|
||||
import { API, json, pathOf } from '../helpers'
|
||||
|
||||
const unauthenticated = { error: { code: 'unauthenticated', message: 'Unauthenticated', details: {} } }
|
||||
const refreshed = (expiresIn: unknown) => json(200, { data: { token_type: 'cookie', expires_in: expiresIn }, meta: {} })
|
||||
const navigation = { data: [], meta: {} }
|
||||
|
||||
/** fetch mock driven by a handler; every request is recorded. */
|
||||
function serve(handler: (request: Request) => Response | Promise<Response>): Request[] {
|
||||
const seen: Request[] = []
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const request = input as Request
|
||||
seen.push(request)
|
||||
return handler(request)
|
||||
})
|
||||
return seen
|
||||
}
|
||||
|
||||
const count = (seen: Request[], path: string) => seen.filter((request) => pathOf(request) === `${API}${path}`).length
|
||||
|
||||
beforeEach(() => {
|
||||
onUnauthorized(null)
|
||||
onRefreshed(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
onUnauthorized(null)
|
||||
onRefreshed(null)
|
||||
})
|
||||
|
||||
describe('transport', () => {
|
||||
it('sends X-Requested-With and same-origin credentials on every request to the runtime API base', async () => {
|
||||
const seen = serve(() => json(200, navigation))
|
||||
await api.GET('/navigation')
|
||||
await api.POST('/auth/login', { body: { login: 'dev', password: 'x' } })
|
||||
expect(REQUESTED_WITH).toBe('XMLHttpRequest')
|
||||
for (const request of seen) {
|
||||
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(request.credentials).toBe('same-origin')
|
||||
expect(pathOf(request).startsWith(API)).toBe(true)
|
||||
}
|
||||
expect(seen.map(pathOf)).toEqual([`${API}/navigation`, `${API}/auth/login`])
|
||||
})
|
||||
|
||||
it('passes a 401 from login or refresh through without refreshing', async () => {
|
||||
const seen = serve(() => json(401, unauthenticated))
|
||||
const handler = vi.fn()
|
||||
onUnauthorized(handler)
|
||||
|
||||
const login = await api.POST('/auth/login', { body: { login: 'dev', password: 'bad' } })
|
||||
const refresh = await api.POST('/auth/refresh')
|
||||
|
||||
expect(login.response.status).toBe(401)
|
||||
expect(refresh.response.status).toBe(401)
|
||||
expect(count(seen, '/auth/refresh')).toBe(1)
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves other statuses alone', async () => {
|
||||
const seen = serve(() => json(403, { error: { code: 'forbidden', message: 'no', details: {} } }))
|
||||
const handler = vi.fn()
|
||||
onUnauthorized(handler)
|
||||
const result = await api.GET('/navigation')
|
||||
expect(result.response.status).toBe(403)
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('single-flights one refresh for concurrent 401s and replays each request once with its body', async () => {
|
||||
let session = false
|
||||
const seen = serve(async (request) => {
|
||||
if (pathOf(request) === `${API}/auth/refresh`) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
session = true
|
||||
return refreshed(900)
|
||||
}
|
||||
if (!session) {
|
||||
return json(401, unauthenticated)
|
||||
}
|
||||
if (request.method === 'PUT') {
|
||||
return json(200, { data: await request.json(), meta: { labels: {} } })
|
||||
}
|
||||
return json(200, navigation)
|
||||
})
|
||||
const lifetimes: Array<number | null> = []
|
||||
onRefreshed((seconds) => lifetimes.push(seconds))
|
||||
const handler = vi.fn()
|
||||
onUnauthorized(handler)
|
||||
|
||||
const path = { vendor: 'acme', plugin: 'demo', controller: 'widgets', id: 1 }
|
||||
const [a, b, put] = await Promise.all([
|
||||
api.GET('/navigation'),
|
||||
api.GET('/navigation'),
|
||||
api.PUT('/{vendor}/{plugin}/{controller}/{id}', { params: { path }, body: { name: 'Replayed' } }),
|
||||
])
|
||||
|
||||
expect(a.response.status).toBe(200)
|
||||
expect(b.response.status).toBe(200)
|
||||
expect(put.data?.data).toEqual({ name: 'Replayed' })
|
||||
expect(count(seen, '/auth/refresh')).toBe(1)
|
||||
expect(count(seen, '/navigation')).toBe(4)
|
||||
expect(count(seen, '/acme/demo/widgets/1')).toBe(2)
|
||||
expect(lifetimes).toEqual([900])
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the session gone when the refresh fails, without replaying', async () => {
|
||||
const seen = serve((request) =>
|
||||
pathOf(request) === `${API}/auth/refresh` ? json(401, unauthenticated) : json(401, unauthenticated),
|
||||
)
|
||||
const handler = vi.fn()
|
||||
onUnauthorized(handler)
|
||||
const result = await api.GET('/navigation')
|
||||
expect(result.response.status).toBe(401)
|
||||
expect(count(seen, '/navigation')).toBe(1)
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports the session gone when the replay is still 401, and replays only once', async () => {
|
||||
const seen = serve((request) => (pathOf(request) === `${API}/auth/refresh` ? refreshed(60) : json(401, unauthenticated)))
|
||||
const handler = vi.fn()
|
||||
onUnauthorized(handler)
|
||||
const result = await api.GET('/navigation')
|
||||
expect(result.response.status).toBe(401)
|
||||
expect(count(seen, '/navigation')).toBe(2)
|
||||
expect(count(seen, '/auth/refresh')).toBe(1)
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshSession', () => {
|
||||
it('shares one in-flight request between concurrent callers and starts a new one afterwards', async () => {
|
||||
const seen = serve(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
return refreshed(120)
|
||||
})
|
||||
const first = refreshSession()
|
||||
const second = refreshSession()
|
||||
expect(second).toBe(first)
|
||||
expect(await first).toBe(true)
|
||||
expect(count(seen, '/auth/refresh')).toBe(1)
|
||||
expect(await refreshSession()).toBe(true)
|
||||
expect(count(seen, '/auth/refresh')).toBe(2)
|
||||
})
|
||||
|
||||
it('reports a missing lifetime as null', async () => {
|
||||
serve(() => refreshed('soon'))
|
||||
const lifetimes: Array<number | null> = []
|
||||
onRefreshed((seconds) => lifetimes.push(seconds))
|
||||
expect(await refreshSession()).toBe(true)
|
||||
expect(lifetimes).toEqual([null])
|
||||
})
|
||||
|
||||
it('answers false on an error status or a network failure', async () => {
|
||||
serve(() => json(500, { error: { code: 'server', message: 'x', details: {} } }))
|
||||
expect(await refreshSession()).toBe(false)
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network'))
|
||||
expect(await refreshSession()).toBe(false)
|
||||
})
|
||||
})
|
||||
39
admin/tests/app/controllerRoutes.test.ts
Normal file
39
admin/tests/app/controllerRoutes.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
controllerIdFromParams,
|
||||
controllerIdFromPath,
|
||||
controllerPath,
|
||||
parseControllerId,
|
||||
pluginKey,
|
||||
} from '../../src/app/controllerRoutes'
|
||||
|
||||
describe('controller routes (D-10)', () => {
|
||||
it('parses a three-segment controller id', () => {
|
||||
expect(parseControllerId('acme.demo.widgets')).toEqual({ vendor: 'acme', plugin: 'demo', controller: 'widgets' })
|
||||
expect(parseControllerId('a_b.c-d.E9')).toEqual({ vendor: 'a_b', plugin: 'c-d', controller: 'E9' })
|
||||
})
|
||||
|
||||
it.each(['', 'acme.demo', 'acme.demo.widgets.extra', 'acme..widgets', 'acme.demo.wid gets', 'acme/demo/widgets', '../x.y.z'])(
|
||||
'rejects %j',
|
||||
(id) => {
|
||||
expect(parseControllerId(id)).toBeNull()
|
||||
expect(controllerPath(id)).toBeNull()
|
||||
expect(pluginKey(id)).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('maps ids and paths both ways', () => {
|
||||
expect(controllerPath('acme.demo.widgets')).toBe('/acme/demo/widgets')
|
||||
expect(controllerIdFromParams({ vendor: 'acme', plugin: 'demo', controller: 'widgets' })).toBe('acme.demo.widgets')
|
||||
expect(controllerIdFromPath('/acme/demo/widgets')).toBe('acme.demo.widgets')
|
||||
expect(controllerIdFromPath('/acme/demo/widgets/12')).toBe('acme.demo.widgets')
|
||||
expect(controllerIdFromPath('acme/demo/widgets/')).toBe('acme.demo.widgets')
|
||||
expect(controllerIdFromPath('/settings')).toBeNull()
|
||||
expect(controllerIdFromPath('/a/b')).toBeNull()
|
||||
expect(controllerIdFromPath('/a/b c/d')).toBeNull()
|
||||
})
|
||||
|
||||
it('names the owning plugin of a controller', () => {
|
||||
expect(pluginKey('acme.demo.widgets')).toBe('acme.demo')
|
||||
})
|
||||
})
|
||||
137
admin/tests/app/i18n.test.ts
Normal file
137
admin/tests/app/i18n.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
currentLocale,
|
||||
interpolate,
|
||||
loadStrings,
|
||||
message,
|
||||
pluralCategory,
|
||||
setBundle,
|
||||
t,
|
||||
tc,
|
||||
} from '../../src/app/i18n'
|
||||
import { API, mockApi, requestsTo } from '../helpers'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
|
||||
beforeEach(() => {
|
||||
setBundle(langFixture.data, 'pl')
|
||||
})
|
||||
|
||||
describe('interpolate (parity with phrasebook.interpolate)', () => {
|
||||
it('replaces :name, :Name and :NAME with the plain, first-upper and upper value', () => {
|
||||
expect(interpolate(':name / :Name / :NAME', { name: 'ada' })).toBe('ada / Ada / ADA')
|
||||
})
|
||||
|
||||
it('replaces the longer placeholder first so :names never loses to :name', () => {
|
||||
expect(interpolate(':names and :name', { name: 'one', names: 'many' })).toBe('many and one')
|
||||
expect(interpolate(':name and :names', { names: 'many', name: 'one' })).toBe('one and many')
|
||||
})
|
||||
|
||||
it('accepts a leading colon on the parameter name and skips an empty name', () => {
|
||||
expect(interpolate('Hello :who', { ':who': 'world' })).toBe('Hello world')
|
||||
expect(interpolate('Hello :', { ':': 'x', '': 'y' })).toBe('Hello :')
|
||||
})
|
||||
|
||||
it('upper-cases the first code point, not the first UTF-16 unit', () => {
|
||||
expect(interpolate(':Name', { name: 'łódź' })).toBe('Łódź')
|
||||
expect(interpolate(':Name', { name: '𝒶bc' })).toBe('𝒶bc')
|
||||
})
|
||||
|
||||
it('stringifies numbers and leaves text without placeholders or params alone', () => {
|
||||
expect(interpolate(':count items', { count: 3 })).toBe('3 items')
|
||||
expect(interpolate('plain', { name: 'x' })).toBe('plain')
|
||||
expect(interpolate(':name')).toBe(':name')
|
||||
expect(interpolate('', { name: 'x' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plural selection', () => {
|
||||
it.each([
|
||||
[1, 'one'],
|
||||
[2, 'few'],
|
||||
[5, 'many'],
|
||||
[22, 'few'],
|
||||
[25, 'many'],
|
||||
])('pl %i is %s', (count, category) => {
|
||||
expect(pluralCategory(count, 'pl')).toBe(category)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, 'one'],
|
||||
[2, 'other'],
|
||||
])('en %i is %s', (count, category) => {
|
||||
expect(pluralCategory(count, 'en')).toBe(category)
|
||||
})
|
||||
|
||||
it('answers other for a locale Intl cannot parse', () => {
|
||||
expect(pluralCategory(1, '!!invalid!!')).toBe('other')
|
||||
})
|
||||
|
||||
it('uses the bundle locale by default', () => {
|
||||
setBundle(langFixture.data, 'en')
|
||||
expect(pluralCategory(2)).toBe('other')
|
||||
setBundle(langFixture.data, 'pl')
|
||||
expect(pluralCategory(2)).toBe('few')
|
||||
})
|
||||
|
||||
it('picks the form for the count and falls back to other when that form is missing', () => {
|
||||
const forms = { one: ':count plik', other: ':count pliku' }
|
||||
expect(message(forms, 1)).toBe('1 plik')
|
||||
expect(message(forms, 5)).toBe('5 pliku')
|
||||
expect(message(forms, 2, { count: 'dwa' })).toBe('dwa pliku')
|
||||
})
|
||||
|
||||
it('renders other without a count and nothing without forms', () => {
|
||||
expect(message({ other: 'Hi :name', one: 'x' }, undefined, { name: 'Ada' })).toBe('Hi Ada')
|
||||
expect(message(null)).toBe('')
|
||||
expect(message(undefined, 3)).toBe('')
|
||||
expect(message({ one: 'only one' }, 5)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bundle lookups', () => {
|
||||
it('t returns the key itself when the key is not in the bundle', () => {
|
||||
expect(t('backend::lang.missing.key')).toBe('backend::lang.missing.key')
|
||||
expect(tc('backend::lang.missing.key', 3)).toBe('backend::lang.missing.key')
|
||||
})
|
||||
|
||||
it('t interpolates the other form and tc the plural form', () => {
|
||||
expect(t('backend::lang.form.remove_item', { name: 'Ada' })).toBe('Usuń: Ada')
|
||||
expect(tc('backend::lang.list.results', 1)).toBe('1 wynik')
|
||||
expect(tc('backend::lang.list.results', 3)).toBe('3 wyniki')
|
||||
expect(tc('backend::lang.list.results', 7)).toBe('7 wyników')
|
||||
})
|
||||
|
||||
it('setBundle replaces the strings and sets the document language', () => {
|
||||
setBundle({ 'backend::lang.auth.title': { other: 'Welcome back' } }, 'en')
|
||||
expect(t('backend::lang.auth.title')).toBe('Welcome back')
|
||||
expect(t('backend::lang.auth.submit')).toBe('backend::lang.auth.submit')
|
||||
expect(document.documentElement.lang).toBe('en')
|
||||
expect(currentLocale.value).toBe('en')
|
||||
})
|
||||
|
||||
it('setBundle without a locale keeps the current locale', () => {
|
||||
setBundle({}, 'pl')
|
||||
setBundle({ a: { other: 'b' } })
|
||||
expect(currentLocale.value).toBe('pl')
|
||||
expect(t('a')).toBe('b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadStrings', () => {
|
||||
it('loads GET /lang and adopts meta.locale', async () => {
|
||||
const calls = mockApi({
|
||||
[`GET ${API}/lang`]: { body: { data: { 'backend::lang.auth.title': { other: 'Hello' } }, meta: { locale: 'en' } } },
|
||||
})
|
||||
await loadStrings()
|
||||
expect(requestsTo(calls, 'GET', `${API}/lang`)).toHaveLength(1)
|
||||
expect(t('backend::lang.auth.title')).toBe('Hello')
|
||||
expect(currentLocale.value).toBe('en')
|
||||
})
|
||||
|
||||
it('keeps the current strings when the bundle cannot be loaded', async () => {
|
||||
mockApi({ [`GET ${API}/lang`]: { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } } })
|
||||
await loadStrings()
|
||||
expect(t('backend::lang.auth.title')).toBe('Witaj ponownie')
|
||||
expect(currentLocale.value).toBe('pl')
|
||||
})
|
||||
})
|
||||
34
admin/tests/app/icons.test.ts
Normal file
34
admin/tests/app/icons.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Archive, Disc3, Settings, Square, Tags } from '@lucide/vue'
|
||||
import { fallbackIcon, iconFor, icons, winterIcons } from '../../src/app/icons'
|
||||
|
||||
describe('iconFor', () => {
|
||||
it('resolves lucide names', () => {
|
||||
expect(iconFor('disc-3')).toBe(Disc3)
|
||||
expect(iconFor('tags')).toBe(Tags)
|
||||
expect(iconFor(' tags ')).toBe(Tags)
|
||||
})
|
||||
|
||||
it('resolves Winter icon aliases', () => {
|
||||
expect(iconFor('icon-archive')).toBe(Archive)
|
||||
expect(iconFor('icon-cog')).toBe(Settings)
|
||||
})
|
||||
|
||||
it('falls back to the neutral square for unknown, empty or missing names', () => {
|
||||
expect(fallbackIcon).toBe(Square)
|
||||
expect(iconFor('unknown-icon-name')).toBe(Square)
|
||||
expect(iconFor('icon-unknown')).toBe(Square)
|
||||
expect(iconFor('')).toBe(Square)
|
||||
expect(iconFor(null)).toBe(Square)
|
||||
expect(iconFor(undefined)).toBe(Square)
|
||||
expect(iconFor('constructor')).toBe(Square)
|
||||
expect(iconFor('toString')).toBe(Square)
|
||||
expect(iconFor('__proto__')).toBe(Square)
|
||||
})
|
||||
|
||||
it('maps every Winter alias onto a registered lucide icon', () => {
|
||||
for (const [alias, name] of Object.entries(winterIcons)) {
|
||||
expect(icons[name], alias).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
74
admin/tests/app/listQuery.test.ts
Normal file
74
admin/tests/app/listQuery.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { listQueryKey, parseListQuery, toListQuery } from '../../src/app/listQuery'
|
||||
|
||||
describe('parseListQuery', () => {
|
||||
it('reads search, sort, dir, page, per_page and filter[<name>]', () => {
|
||||
expect(
|
||||
parseListQuery({
|
||||
search: ' blue ',
|
||||
sort: 'name',
|
||||
dir: 'desc',
|
||||
page: '3',
|
||||
per_page: '50',
|
||||
'filter[active]': 'true',
|
||||
'filter[created]': '2026-01-01..2026-02-01',
|
||||
}),
|
||||
).toEqual({
|
||||
search: 'blue',
|
||||
sort: 'name',
|
||||
dir: 'desc',
|
||||
page: 3,
|
||||
per_page: 50,
|
||||
filter: { active: 'true', created: '2026-01-01..2026-02-01' },
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['0', '-1', '1.5', 'abc', '', '01', ' 2'])('drops the invalid page and per_page %j', (value) => {
|
||||
const out = parseListQuery({ page: value, per_page: value })
|
||||
expect(out.page).toBeUndefined()
|
||||
expect(out.per_page).toBeUndefined()
|
||||
})
|
||||
|
||||
it('defaults the direction to asc with a sort and ignores a direction without one', () => {
|
||||
expect(parseListQuery({ sort: 'name', dir: 'sideways' })).toEqual({ sort: 'name', dir: 'asc' })
|
||||
expect(parseListQuery({ dir: 'desc' })).toEqual({})
|
||||
expect(parseListQuery({ sort: ' ' })).toEqual({})
|
||||
})
|
||||
|
||||
it('takes the last value of a repeated key and drops empty filters and non-filter keys', () => {
|
||||
expect(parseListQuery({ search: ['a', 'b'], 'filter[x]': '', 'filter[]': 'y', filters: 'z', other: '1' })).toEqual({
|
||||
search: 'b',
|
||||
})
|
||||
expect(parseListQuery({ page: ['2', null] })).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toListQuery', () => {
|
||||
it('writes only meaningful values and omits page 1', () => {
|
||||
expect(toListQuery({})).toEqual({})
|
||||
expect(toListQuery({ page: 1, search: '' })).toEqual({})
|
||||
expect(toListQuery({ page: 2, per_page: 10 })).toEqual({ page: '2', per_page: '10' })
|
||||
expect(toListQuery({ sort: 'code' })).toEqual({ sort: 'code', dir: 'asc' })
|
||||
expect(toListQuery({ filter: { b: '2', a: '1', c: '' } })).toEqual({ 'filter[a]': '1', 'filter[b]': '2' })
|
||||
})
|
||||
|
||||
it('round-trips through parseListQuery', () => {
|
||||
const input = {
|
||||
search: 'blue',
|
||||
sort: 'name',
|
||||
dir: 'desc' as const,
|
||||
page: 4,
|
||||
per_page: 25,
|
||||
filter: { active: 'false', maker: '2' },
|
||||
}
|
||||
const raw = toListQuery(input)
|
||||
const back = parseListQuery(raw as Record<string, string>)
|
||||
expect(back).toEqual(input)
|
||||
})
|
||||
|
||||
it('keys equal queries alike regardless of filter order', () => {
|
||||
expect(listQueryKey({ filter: { a: '1', b: '2' } })).toBe(listQueryKey({ filter: { b: '2', a: '1' } }))
|
||||
expect(listQueryKey({ page: 1 })).toBe(listQueryKey({}))
|
||||
expect(listQueryKey({ page: 2 })).not.toBe(listQueryKey({}))
|
||||
})
|
||||
})
|
||||
95
admin/tests/app/router.test.ts
Normal file
95
admin/tests/app/router.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import { createAdminRouter, safeRedirect } from '../../src/app/router'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { navigationFixture } from '../fixtures/typed'
|
||||
import { resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('safeRedirect', () => {
|
||||
it('accepts in-app paths with exactly one leading slash', () => {
|
||||
expect(safeRedirect('/acme/demo/widgets')).toBe('/acme/demo/widgets')
|
||||
expect(safeRedirect('/acme/demo/widgets/1?tab=2#x')).toBe('/acme/demo/widgets/1?tab=2#x')
|
||||
expect(safeRedirect('/')).toBe('/')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['protocol-relative', '//evil.example/x'],
|
||||
['backslash protocol-relative', '/\\evil.example'],
|
||||
['absolute http', 'http://evil.example/'],
|
||||
['absolute https', 'https://evil.example/'],
|
||||
['javascript', 'javascript:alert(1)'],
|
||||
['relative', 'acme/demo'],
|
||||
['empty', ''],
|
||||
])('rejects a %s value', (_label, value) => {
|
||||
expect(safeRedirect(value)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects non-string values', () => {
|
||||
expect(safeRedirect(undefined)).toBeNull()
|
||||
expect(safeRedirect(null)).toBeNull()
|
||||
expect(safeRedirect(['/a'])).toBeNull()
|
||||
expect(safeRedirect(1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('route guard', () => {
|
||||
it('sends a visitor without a session to login with the requested path', async () => {
|
||||
const router = await routerAt('/acme/demo/widgets/3?x=1')
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets/3?x=1')
|
||||
})
|
||||
|
||||
it('lets a visitor open the login screen', async () => {
|
||||
const router = await routerAt('/login')
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('moves a signed-in admin away from login to a safe redirect', async () => {
|
||||
await signIn()
|
||||
const router = await routerAt('/login?redirect=/acme/demo/gadgets')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/gadgets')
|
||||
})
|
||||
|
||||
it.each(['//evil.example/x', 'https://evil.example/', '/\\evil'])(
|
||||
'ignores the unsafe redirect %j and goes home',
|
||||
async (redirect) => {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push({ path: '/login', query: { redirect } })
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
},
|
||||
)
|
||||
|
||||
it('lands home on the first permitted controller, or on the empty page without navigation', async () => {
|
||||
await signIn()
|
||||
setNavigation(navigationFixture.data)
|
||||
expect((await routerAt('/')).currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
setNavigation([])
|
||||
const empty = await routerAt('/')
|
||||
expect(empty.currentRoute.value.name).toBe('home')
|
||||
})
|
||||
|
||||
it('names the list, create, record, settings and not-found routes', async () => {
|
||||
await signIn()
|
||||
const cases: Array<[string, string]> = [
|
||||
['/acme/demo/widgets', 'list'],
|
||||
['/acme/demo/widgets/create', 'create'],
|
||||
['/acme/demo/widgets/12', 'record'],
|
||||
['/acme/demo/widgets/abc', 'not-found'],
|
||||
['/settings', 'settings'],
|
||||
['/settings/mail', 'settings-form'],
|
||||
['/nowhere', 'not-found'],
|
||||
]
|
||||
for (const [path, name] of cases) {
|
||||
const router = await routerAt(path)
|
||||
expect(router.currentRoute.value.name, path).toBe(name)
|
||||
expect(router.currentRoute.value.meta.shell, path).toBe(true)
|
||||
}
|
||||
expect((await routerAt('/acme/demo/widgets/12')).currentRoute.value.params.id).toBe('12')
|
||||
})
|
||||
})
|
||||
41
admin/tests/app/runtime.test.ts
Normal file
41
admin/tests/app/runtime.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_BASE, readBase, runtime } from '../../src/app/runtime'
|
||||
|
||||
function docWith(content: string | null): Document {
|
||||
const doc = document.implementation.createHTMLDocument('test')
|
||||
if (content !== null) {
|
||||
const meta = doc.createElement('meta')
|
||||
meta.setAttribute('name', 'summer-admin-base')
|
||||
meta.setAttribute('content', content)
|
||||
doc.head.appendChild(meta)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
describe('runtime base', () => {
|
||||
it('derives the router base and the API base from the served meta', () => {
|
||||
expect(runtime.base).toBe('/admin-test')
|
||||
expect(runtime.api).toBe('/admin-test/api/v1')
|
||||
})
|
||||
|
||||
it('reads a custom prefix and trims trailing slashes and whitespace', () => {
|
||||
expect(readBase(docWith('/acp'))).toBe('/acp')
|
||||
expect(readBase(docWith(' /acp/panel// '))).toBe('/acp/panel')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing meta', null],
|
||||
['an empty value', ''],
|
||||
['the unreplaced build token', '__SUMMER_ADMIN_BASE__'],
|
||||
['a relative path', 'backend'],
|
||||
['an absolute URL', 'https://evil.example/backend'],
|
||||
['the root alone', '/'],
|
||||
['slashes only', '///'],
|
||||
])('falls back to the default for %s', (_label, content) => {
|
||||
expect(readBase(docWith(content))).toBe(DEFAULT_BASE)
|
||||
})
|
||||
|
||||
it('uses /backend as the default', () => {
|
||||
expect(DEFAULT_BASE).toBe('/backend')
|
||||
})
|
||||
})
|
||||
34
admin/tests/app/theme.test.ts
Normal file
34
admin/tests/app/theme.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { applyColorScheme, DARK_QUERY } from '../../src/app/theme'
|
||||
import { fakeMedia } from '../helpers'
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove('dark')
|
||||
})
|
||||
|
||||
describe('applyColorScheme (A6: system preference only)', () => {
|
||||
it('sets .dark from prefers-color-scheme and follows changes until unsubscribed', () => {
|
||||
const media = fakeMedia({ [DARK_QUERY]: true })
|
||||
const stop = applyColorScheme()
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
expect(media.listeners(DARK_QUERY)).toBe(1)
|
||||
|
||||
media.change(DARK_QUERY, false)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
media.change(DARK_QUERY, true)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
|
||||
stop()
|
||||
expect(media.listeners(DARK_QUERY)).toBe(0)
|
||||
media.change(DARK_QUERY, false)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
})
|
||||
|
||||
it('stays light without matchMedia', () => {
|
||||
document.documentElement.classList.add('dark')
|
||||
const win = { document } as unknown as Window
|
||||
const stop = applyColorScheme(win)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
expect(stop()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
53
admin/tests/app/winterUrl.test.ts
Normal file
53
admin/tests/app/winterUrl.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapWinterUrl } from '../../src/app/winterUrl'
|
||||
|
||||
const ID = 'acme.demo.widgets'
|
||||
const LIST = '/acme/demo/widgets'
|
||||
|
||||
describe('mapWinterUrl', () => {
|
||||
it('maps an empty, null or missing URL to the list', () => {
|
||||
expect(mapWinterUrl('', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl(null, ID)).toBe(LIST)
|
||||
expect(mapWinterUrl(undefined, ID)).toBe(LIST)
|
||||
expect(mapWinterUrl(' ', ID)).toBe(LIST)
|
||||
})
|
||||
|
||||
it('maps the controller root and create', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('/acme/demo/widgets/', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/create', ID)).toBe(`${LIST}/create`)
|
||||
})
|
||||
|
||||
it('substitutes :id in update/:id and accepts a literal numeric id', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, 42)).toBe(`${LIST}/42`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, '7')).toBe(`${LIST}/7`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/9', ID)).toBe(`${LIST}/9`)
|
||||
})
|
||||
|
||||
it('falls back to the list when update has no usable id', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, null)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, 'abc')).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/1e3', ID)).toBe(LIST)
|
||||
})
|
||||
|
||||
it('never leaves the current controller for a foreign or absolute URL (T-10-20)', () => {
|
||||
expect(mapWinterUrl('acme/demo/gadgets/update/:id', ID, 1)).toBe(LIST)
|
||||
expect(mapWinterUrl('https://evil.example/acme/demo/widgets/create', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('//evil.example/x', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('javascript:alert(1)', ID)).toBe(LIST)
|
||||
})
|
||||
|
||||
it('ignores the query and hash, matches the owner case-insensitively and rejects unknown actions', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/create?x=1#top', ID)).toBe(`${LIST}/create`)
|
||||
expect(mapWinterUrl('Acme/Demo/Widgets/update/:id', ID, 3)).toBe(`${LIST}/3`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/3', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/3/extra', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/CREATE', ID)).toBe(LIST)
|
||||
})
|
||||
|
||||
it('answers the root for a malformed controller id', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets', 'acme.demo')).toBe('/')
|
||||
expect(mapWinterUrl('acme/demo/widgets', 'acme.demo.wid gets')).toBe('/')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user