Files
vue-menu-layer/tests/menuLinks.test.ts
Jakub Zych 2deac88c0e 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
2026-10-12 19:46:29 +02:00

207 lines
9.4 KiB
TypeScript

// Link rules of the menu: the URL allowlist (javascript: and data: never get
// an href), rel on new-window links, and active / trail matching. The last
// tests compile Menu/NavItem.vue and render it on the server, so the rules
// are proven on the markup too. No network, no Nuxt.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderNavItem } from './render.ts'
import {
entryHref,
entryPath,
fillTitle,
inTrail,
isActive,
isAllowedUrl,
linkKind,
linkRel,
normalizePath,
} from '../app/utils/menuLinks.ts'
import type { MenuEntry } from '../app/utils/types.ts'
function entry(url: string | null, extra: Partial<MenuEntry> = {}): MenuEntry {
return { id: 1, title: 'T', type: 'url', url, target: null, css_class: null, icon: null, code: null, items: [], ...extra }
}
test('the allowlist takes http, https, mailto, tel, / and #', () => {
for (const url of ['http://a.test/', 'HTTPS://a.test', 'mailto:a@b.test', 'tel:+48123', '/blog', '#faq', '//cdn.test/x']) {
assert.equal(isAllowedUrl(url), true, url)
}
for (const url of [
'javascript:alert(1)',
'JavaScript:alert(1)',
'data:text/html,<b>x</b>',
'vbscript:x',
'blog',
'rodzaje/reishi',
' /blog',
'/blog\n',
'\tjavascript:alert(1)',
'',
null,
]) {
assert.equal(isAllowedUrl(url), false, String(url))
}
})
test('javascript: and data: URLs render as text with no href', () => {
for (const url of ['javascript:alert(1)', 'data:text/html;base64,PHNjcmlwdD4=']) {
assert.equal(entryHref(entry(url)), null)
assert.equal(linkKind(entry(url)), 'text')
}
})
test('link kinds: router for site paths, anchor for absolute, mailto, tel and fragments', () => {
assert.equal(linkKind(entry('/blog')), 'router')
assert.equal(linkKind(entry('https://a.test/x')), 'anchor')
assert.equal(linkKind(entry('mailto:a@b.test')), 'anchor')
assert.equal(linkKind(entry('tel:+48123')), 'anchor')
assert.equal(linkKind(entry('#faq')), 'anchor')
assert.equal(linkKind(entry('//cdn.test/x')), 'anchor')
assert.equal(linkKind(entry('/\\evil.test')), 'anchor')
// A bare "#" (a dropdown placeholder) and null are no link.
assert.equal(linkKind(entry('#')), 'text')
assert.equal(linkKind(entry(null)), 'text')
})
test('_blank links get rel noopener noreferrer', () => {
assert.equal(linkRel('_blank'), 'noopener noreferrer')
assert.equal(linkRel('_self'), undefined)
assert.equal(linkRel(null), undefined)
})
test('paths normalize: query, hash and trailing slash ignored, decoded', () => {
assert.equal(normalizePath('/blog/'), '/blog')
assert.equal(normalizePath('/blog?page=2#x'), '/blog')
assert.equal(normalizePath('/'), '/')
assert.equal(normalizePath(''), '/')
assert.equal(normalizePath('/grzyby%20le%C5%9Bne'), '/grzyby leśne')
assert.equal(normalizePath('/bad%E0%A4%A'), '/bad%E0%A4%A')
})
test('active matching compares paths; absolute URLs only on the request host', () => {
assert.equal(isActive(entry('/blog/'), '/blog', 'site.test'), true)
assert.equal(isActive(entry('/blog?x=1'), '/blog/', 'site.test'), true)
assert.equal(isActive(entry('/blog'), '/blog/post', 'site.test'), false)
assert.equal(isActive(entry('https://site.test/blog'), '/blog', 'site.test'), true)
assert.equal(isActive(entry('https://other.test/blog'), '/blog', 'site.test'), false)
assert.equal(isActive(entry('#'), '/', 'site.test'), false)
assert.equal(entryPath(entry('mailto:a@b.test'), 'site.test'), null)
})
test('the trail marks ancestors of the active entry', () => {
const tree = entry('#', { items: [entry('/a'), entry(null, { items: [entry('/deep')] })] })
assert.equal(inTrail(tree, '/deep', 'site.test'), true)
assert.equal(inTrail(tree, '/a/', 'site.test'), true)
assert.equal(inTrail(tree, '/other', 'site.test'), false)
})
test('fillTitle fills :title', () => {
assert.equal(fillTitle('Show submenu: :title', 'Grzyby'), 'Show submenu: Grzyby')
})
test('rendered: unsafe URLs are a span without href, _blank links carry rel', async () => {
const html = await renderNavItem({
entry: entry('#', {
title: 'Parent',
items: [
entry('javascript:alert(1)', { id: 2, title: 'Js' }),
entry('data:text/html,x', { id: 3, title: 'Data' }),
entry('https://ext.test/', { id: 4, title: 'Ext', target: '_blank' }),
entry('/here', { id: 5, title: 'Here' }),
entry(null, { id: 6, title: '', type: 'divider' }),
],
}),
routePath: '/here/',
})
assert.ok(!html.includes('javascript:'), html)
assert.ok(!html.includes('data:text'), html)
assert.match(html, /<span class="menu-nav__text"[^>]*>Js<\/span>/)
assert.match(html, /<span class="menu-nav__text"[^>]*>Data<\/span>/)
assert.match(html, /<a href="https:\/\/ext\.test\/" class="menu-nav__link" target="_blank" rel="noopener noreferrer"[^>]*>Ext<\/a>/)
// The parent has no URL: one disclosure button; the active child is marked.
assert.match(html, /<button type="button" class="menu-nav__link menu-nav__subtoggle" aria-expanded="false" aria-controls="m-0-sub">/)
assert.match(html, /aria-current="page"[^>]*>Here</)
assert.match(html, /class="menu-nav__item menu-nav__item--parent menu-nav__item--trail"/)
assert.match(html, /<li class="menu-nav__divider" aria-hidden="true"><\/li>/)
})
test('rendered: an entry with children and a URL keeps its link and gets a labelled toggle', async () => {
const html = await renderNavItem({
entry: entry('/blog', { title: 'Blog', items: [entry('/blog/a', { id: 9, title: 'A' })] }),
routePath: '/',
open: true,
})
assert.match(html, /<a href="\/blog" class="menu-nav__link"[^>]*>Blog<\/a>/)
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)
})