- 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
207 lines
9.4 KiB
TypeScript
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)
|
|
})
|