test(17-06): menu layer link and client tests

- every refused scheme and spacing renders as text; protocol-relative
  URLs are anchors, never site paths or active; rel only for _blank;
  active matching rules and same-host absolute URLs; the trail marks
  ancestors only
- useMenuApi uses the server base with the forwarded visitor on the
  server and the public base in the browser
This commit is contained in:
2026-10-12 19:46:29 +02:00
parent d980f4cf2f
commit 2deac88c0e
2 changed files with 125 additions and 0 deletions

View File

@@ -76,3 +76,59 @@ test('isNotFound reads status, statusCode and response.status', () => {
assert.equal(isNotFound({ status: 500 }), false)
assert.equal(isNotFound(null), false)
})
// Plan 06 coverage: useMenuApi picks the base. The composable reads Nuxt
// globals and import.meta.server, so the test loads its source with
// import.meta.server replaced by a flag, next to the original (its relative
// import resolves), with stubbed globals.
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
type FetchCall = { path: string; opts: { baseURL: string; query?: Record<string, string>; headers?: Record<string, string> } }
let loads = 0
async function useMenuApiAs(server: boolean, request: { context?: Record<string, unknown>; xff?: string; peer?: string } = {}) {
const calls: FetchCall[] = []
const g = globalThis as Record<string, unknown>
g.__menuLayerServer = server
g.useRuntimeConfig = () => ({ menu: { apiBase: 'http://127.0.0.1:8096/_menu/api/v1' }, public: { menu: { apiBase: '/_menu/api/v1' } } })
g.useRequestEvent = () => ({ context: request.context ?? {}, node: { req: { socket: { remoteAddress: request.peer } } } })
g.useRequestHeaders = () => (request.xff === undefined ? {} : { 'x-forwarded-for': request.xff })
g.$fetch = async (path: string, opts: FetchCall['opts']) => {
calls.push({ path, opts })
return MENU
}
const source = join(dirname(fileURLToPath(import.meta.url)), '..', 'app', 'composables', 'useMenuApi.ts')
const out = join(dirname(source), `.useMenuApi-${process.pid}-${++loads}.compiled.ts`)
writeFileSync(out, readFileSync(source, 'utf8').replaceAll('import.meta.server', '(globalThis as Record<string, unknown>).__menuLayerServer'))
try {
const mod = (await import(pathToFileURL(out).href)) as { useMenuApi: () => ReturnType<typeof createMenuClient> }
return { client: mod.useMenuApi(), calls }
} finally {
rmSync(out, { force: true })
}
}
test('the server base is used on the server, with the visitor forwarded', async () => {
const { client, calls } = await useMenuApiAs(true, { xff: '203.0.113.7', peer: '10.0.0.2' })
await client.getMenu('main menu', 'pl')
assert.deepEqual(calls, [
{ path: '/menus/main%20menu', opts: { baseURL: 'http://127.0.0.1:8096/_menu/api/v1', query: { locale: 'pl' }, headers: { 'x-forwarded-for': '203.0.113.7, 10.0.0.2' } } },
])
// A value recorded on the request context (route cache) wins.
const recorded = await useMenuApiAs(true, { context: { menuForwardedFor: '198.51.100.9' }, xff: '1.1.1.1', peer: '10.0.0.2' })
await recorded.client.getMenu('main')
assert.deepEqual(recorded.calls[0]!.opts.headers, { 'x-forwarded-for': '198.51.100.9' })
// Nothing to forward: no header at all.
const bare = await useMenuApiAs(true)
await bare.client.getMenu('main')
assert.deepEqual(bare.calls[0]!.opts, { baseURL: 'http://127.0.0.1:8096/_menu/api/v1', query: undefined, headers: {} })
})
test('the public base is used in the browser, without forwarded headers', async () => {
const { client, calls } = await useMenuApiAs(false, { xff: '203.0.113.7', peer: '10.0.0.2' })
await client.getMenu('main', 'en')
assert.deepEqual(calls, [{ path: '/menus/main', opts: { baseURL: '/_menu/api/v1', query: { locale: 'en' } } }])
})

View File

@@ -135,3 +135,72 @@ test('rendered: an entry with children and a URL keeps its link and gets a label
assert.match(html, /<button type="button" class="menu-nav__subtoggle" aria-expanded="true" aria-controls="m-0-sub">.*Show submenu: Blog/)
assert.match(html, /menu-nav__item--open/)
})
// Plan 06 coverage of the D-18 link rules.
test('every refused scheme and spacing renders as text without an href', async () => {
for (const url of ['javascript:alert(1)', 'JAVASCRIPT:alert(1)', 'JaVaScRiPt:x', 'data:text/html,x', 'DATA:x', 'vbscript:msgbox(1)', 'VBScript:x', 'file:///etc/passwd', 'ftp://a.test/x', ' https://a.test', 'https://a.test/\u0000']) {
assert.equal(entryHref(entry(url)), null, url)
assert.equal(linkKind(entry(url)), 'text', url)
assert.equal(isActive(entry(url), '/', 'site.test'), false, url)
}
const html = await renderNavItem({
entry: entry('#', {
title: 'Parent',
items: [
entry('JAVASCRIPT:alert(1)', { id: 2, title: 'Upper' }),
entry('vbscript:x', { id: 3, title: 'Vb' }),
],
}),
routePath: '/',
})
assert.ok(!/href="(javascript|vbscript)/i.test(html), html)
assert.match(html, /<span class="menu-nav__text"[^>]*>Upper<\/span>/)
assert.match(html, /<span class="menu-nav__text"[^>]*>Vb<\/span>/)
})
test('protocol-relative URLs are never site paths: an anchor, never active', () => {
for (const url of ['//evil.test/x', '/\\evil.test/x', '//site.test/blog']) {
assert.equal(linkKind(entry(url)), 'anchor', url)
assert.equal(entryPath(entry(url), 'site.test'), null, url)
assert.equal(isActive(entry(url), '/x', 'site.test'), false, url)
assert.equal(isActive(entry(url), '/blog', 'site.test'), false, url)
}
})
test('rel is added only for _blank, whatever the URL kind', () => {
for (const target of ['_blank']) assert.equal(linkRel(target), 'noopener noreferrer')
for (const target of ['_self', '', undefined, '_BLANK', '_top']) assert.equal(linkRel(target as string | undefined), undefined, String(target))
})
test('active matching ignores query, hash and a trailing slash, except that / is only /', () => {
assert.equal(isActive(entry('/'), '/', 'site.test'), true)
assert.equal(isActive(entry('/'), '/?utm=x#top', 'site.test'), true)
assert.equal(isActive(entry('/'), '/blog', 'site.test'), false)
assert.equal(isActive(entry('/blog#x'), '/blog?page=2', 'site.test'), true)
assert.equal(isActive(entry('/blog///'), '/blog', 'site.test'), true)
assert.equal(isActive(entry('/Blog'), '/blog', 'site.test'), false)
assert.equal(isActive(entry('/grzyby%20le%C5%9Bne'), '/grzyby leśne/', 'site.test'), true)
assert.equal(isActive(entry('#'), '/', 'site.test'), false)
assert.equal(isActive(entry('#faq'), '/', 'site.test'), false)
})
test('an absolute URL is active only on the same host', () => {
assert.equal(isActive(entry('https://SITE.test/blog/'), '/blog', 'site.test'), true)
assert.equal(isActive(entry('http://site.test/blog'), '/blog', 'Site.Test'), true)
assert.equal(isActive(entry('https://site.test:8443/blog'), '/blog', 'site.test'), false)
assert.equal(isActive(entry('https://site.test:8443/blog'), '/blog', 'site.test:8443'), true)
assert.equal(isActive(entry('https://other.test/blog'), '/blog', 'site.test'), false)
assert.equal(isActive(entry('https://site.test/blog'), '/blog', ''), false)
assert.equal(isActive(entry('mailto:a@site.test'), '/', 'site.test'), false)
})
test('the trail marks ancestors only, never the active entry itself', () => {
const leaf = entry('/a')
assert.equal(inTrail(leaf, '/a', 'site.test'), false)
const parent = entry('/a', { items: [entry('/a/b')] })
assert.equal(inTrail(parent, '/a', 'site.test'), false)
assert.equal(inTrail(parent, '/a/b', 'site.test'), true)
const deep = entry(null, { items: [entry(null, { items: [entry(null, { items: [entry('/x/y/z/')] })] })] })
assert.equal(inTrail(deep, '/x/y/z', 'site.test'), true)
assert.equal(inTrail(entry('/a', { items: undefined as unknown as MenuEntry[] }), '/a', 'site.test'), false)
})