// 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 { 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,x', '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, /]*>Js<\/span>/) assert.match(html, /]*>Data<\/span>/) assert.match(html, /]*>Ext<\/a>/) // The parent has no URL: one disclosure button; the active child is marked. assert.match(html, /