// PartialHost and the client node allowlist (D-17; UI-SPEC S1, S2, S5; // T-10.1-14): the server node tree is rebuilt with h() under the same lists // the server uses; text is always text. The host fetches through the typed // client (fetch mock), shows its skeleton, empty, failure and busy-refetch // states, and sends ?id= only with a record. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { defineComponent, h } from 'vue' import PartialHost from '../../src/components/partial/PartialHost.vue' import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue' import { PARTIAL_DROPPED_TAGS, PARTIAL_MAX_DEPTH, PARTIAL_MAX_NODES, PARTIAL_TAGS, partialAttrAllowed, renderPartialNodes, } from '../../src/components/partial/partialNodes' import type { PartialNode } from '../../src/api/types' import { extensionPartialFixture } from '../fixtures/typed' import { API, mockApi, queryOf, requestsTo, resetState, type Reply } from '../helpers' const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'widgets' } const STATS = `${API}/acme/demo/widgets/partials/stats` const PARTIAL_FAILED = 'Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.' /** Renders nodes through renderPartialNodes into a real element. */ function renderNodes(nodes: PartialNode[]): HTMLElement { const Probe = defineComponent({ render: () => h('div', { 'data-probe': '' }, renderPartialNodes(nodes)) }) return mount(Probe).element as HTMLElement } function deferred(): { route: () => Promise; release: (reply: Reply) => void } { let release: (reply: Reply) => void = () => undefined const pending = new Promise((resolve) => { release = resolve }) return { route: () => pending, release } } const nodesReply = (nodes: PartialNode[]): Reply => ({ body: { data: { nodes }, meta: {} } }) beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' }) describe('client allowlist (partialNodes)', () => { it('mirrors the server tag lists exactly', () => { expect([...PARTIAL_TAGS].sort()).toEqual( [ 'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'code', 'data', 'dd', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'i', 'img', 'li', 'mark', 'meter', 'ol', 'p', 'pre', 'progress', 'q', 's', 'section', 'small', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u', 'ul', ].sort(), ) expect([...PARTIAL_DROPPED_TAGS].sort()).toEqual( [ 'base', 'button', 'embed', 'form', 'iframe', 'input', 'link', 'math', 'meta', 'noscript', 'object', 'script', 'select', 'style', 'svg', 'template', 'textarea', 'title', 'xmp', ].sort(), ) expect(PARTIAL_MAX_DEPTH).toBe(32) expect(PARTIAL_MAX_NODES).toBe(2000) }) it.each([...PARTIAL_TAGS])('renders an allowed <%s>', (tag) => { const root = renderNodes([{ tag, children: [{ text: 'x' }] }]) expect(root.firstElementChild?.tagName.toLowerCase()).toBe(tag) }) it.each([...PARTIAL_DROPPED_TAGS])('drops <%s> with its subtree', (tag) => { const root = renderNodes([{ text: 'before' }, { tag, children: [{ tag: 'b', children: [{ text: 'inside' }] }] }, { text: 'after' }]) expect(root.textContent).toBe('beforeafter') expect(root.querySelector(tag)).toBeNull() expect(root.querySelector('b')).toBeNull() }) it('unwraps unknown elements and keeps their children, case-insensitively', () => { const root = renderNodes([ { tag: 'custom-box', children: [{ tag: 'span', attrs: { class: 'kept' }, children: [{ text: 'child' }] }] }, { tag: 'ARTICLE', children: [{ text: ' tail' }] }, { tag: 'SCRIPT', children: [{ text: 'alert(1)' }] }, { tag: 'P', children: [{ text: 'upper' }] }, ]) expect(root.querySelector('custom-box')).toBeNull() expect(root.querySelector('article')).toBeNull() expect(root.querySelector('span.kept')?.textContent).toBe('child') expect(root.querySelector('p')?.textContent).toBe('upper') expect(root.textContent).toBe('child tailupper') }) it('keeps text as text, never markup', () => { const hostile = '' const root = renderNodes([{ tag: 'p', children: [{ text: hostile }] }, { text: '' }, { tag: '', text: 'bare' }]) expect(root.querySelector('img')).toBeNull() expect(root.querySelector('script')).toBeNull() expect(root.querySelector('p')?.textContent).toBe(hostile) expect(root.textContent).toBe(`${hostile}bare`) }) it.each([ ['div', 'class', 'x', true], ['div', 'title', 'x', true], ['div', 'lang', 'pl', true], ['div', 'dir', 'rtl', true], ['div', 'role', 'note', true], ['div', 'aria-label', 'x', true], ['div', 'aria-describedby', 'x', true], ['div', 'data-count', '3', true], ['div', 'data-', 'x', false], ['div', 'aria-', 'x', false], ['div', 'id', 'x', false], ['div', 'style', 'color:red', false], ['div', 'onclick', 'x()', false], ['img', 'onerror', 'x()', false], ['div', 'tabindex', '0', false], ['div', 'colspan', '2', false], ['td', 'colspan', '2', true], ['th', 'rowspan', '2', true], ['th', 'scope', 'row', true], ['time', 'datetime', '2026-09-29', true], ['data', 'value', '7', true], ['meter', 'optimum', '1', true], ['progress', 'max', '3', true], ['img', 'alt', 'a', true], ['img', 'width', '10', true], ['a', 'target', '_blank', false], ['a', 'href', '/admin-test/x', true], ['a', 'href', '/', true], ['a', 'href', '#top', true], ['a', 'href', 'javascript:alert(1)', false], ['a', 'href', 'JAVASCRIPT:alert(1)', false], ['a', 'href', 'https://evil.example.test', false], ['a', 'href', '//evil.example.test', false], ['a', 'href', '/\\evil.example.test', false], ['a', 'href', '/\t/evil.example.test', false], ['a', 'href', ' /x', false], ['a', 'href', 'x', false], ['a', 'href', '', false], ['img', 'src', '/a.png', true], ['img', 'src', '#frag', false], ['img', 'src', 'data:image/png;base64,AAAA', false], ['img', 'src', '//evil.example.test/a.png', false], ])('<%s %s=%j> allowed: %s', (tag, name, value, allowed) => { expect(partialAttrAllowed(tag, name, value)).toBe(allowed) }) it('applies the attribute rules to rendered nodes and lower-cases names', () => { const root = renderNodes([ { tag: 'p', attrs: { CLASS: 'lead', ID: 'hijack', OnClick: 'x()', 'Data-Note': 'kept', style: 'color:red' }, children: [{ text: 'p' }] }, { tag: 'a', attrs: { href: 'javascript:alert(1)', title: 'bad' }, children: [{ text: 'bad' }] }, { tag: 'a', attrs: { href: '/admin-test/acme/demo/widgets' }, children: [{ text: 'ok' }] }, ]) const paragraph = root.querySelector('p')! expect(paragraph.getAttributeNames().sort()).toEqual(['class', 'data-note']) const [bad, ok] = Array.from(root.querySelectorAll('a')) expect(bad!.hasAttribute('href')).toBe(false) expect(bad!.getAttribute('title')).toBe('bad') expect(ok!.getAttribute('href')).toBe('/admin-test/acme/demo/widgets') }) it('stops at depth 32', () => { let node: PartialNode = { text: 'deep' } for (let i = 0; i < PARTIAL_MAX_DEPTH + 1; i++) { node = { tag: 'div', children: [node] } } const root = renderNodes([node]) expect(root.querySelectorAll('div').length).toBe(PARTIAL_MAX_DEPTH) expect(root.textContent).toBe('') let fits: PartialNode = { text: 'deep' } for (let i = 0; i < PARTIAL_MAX_DEPTH; i++) { fits = { tag: 'div', children: [fits] } } expect(renderNodes([fits]).textContent).toBe('deep') }) it('renders at most 2000 nodes', () => { const many: PartialNode[] = Array.from({ length: PARTIAL_MAX_NODES + 5 }, (_, i) => ({ tag: 'span', children: [{ text: String(i) }] })) const root = renderNodes(many) // Each span costs two nodes: itself and its text. expect(root.querySelectorAll('span').length).toBe(PARTIAL_MAX_NODES / 2) expect(renderNodes([])).toBeInstanceOf(HTMLElement) expect(renderPartialNodes(undefined)).toEqual([]) }) }) describe('PartialHost', () => { function mountHost(props: { variant?: 'header' | 'field'; recordId?: number | null; reloadKey?: number } = {}) { return mount(PartialHost, { props: { source: SOURCE, name: 'stats', variant: props.variant ?? 'header', recordId: props.recordId, reloadKey: props.reloadKey }, }) } it.each([ ['header', ['h-[80px]', 'w-full', 'rounded-card', 'bg-skel']], ['field', ['h-[44px]', 'w-full', 'rounded-control', 'bg-skel']], ] as const)('shows the %s skeleton on the first load', async (variant, classes) => { const pending = deferred() mockApi({ [`GET ${STATS}`]: pending.route }) const wrapper = mountHost({ variant }) await flushPromises() const loading = wrapper.find('[data-partial-loading]') expect(loading.attributes('aria-busy')).toBe('true') const skeleton = loading.find('[data-partial-skeleton]') expect(skeleton.attributes('aria-hidden')).toBe('true') expect(skeleton.classes()).toEqual(expect.arrayContaining([...classes])) pending.release({ body: extensionPartialFixture }) await flushPromises() expect(wrapper.find('[data-partial-loading]').exists()).toBe(false) const host = wrapper.find(`[data-partial="${variant}"]`) expect(host.classes()).toContain('summer-partial') expect(host.attributes('aria-busy')).toBeUndefined() expect(host.findAll('.summer-stat__label').map((item) => item.text())).toEqual(['All widgets', 'Small']) expect(host.find('script').exists()).toBe(false) expect(host.html()).not.toContain('javascript:') }) it('renders nothing for zero nodes', async () => { mockApi({ [`GET ${STATS}`]: nodesReply([]) }) const wrapper = mountHost() await flushPromises() expect(wrapper.find('[data-partial]').exists()).toBe(false) expect(wrapper.find('[data-partial-loading]').exists()).toBe(false) expect(wrapper.find('[data-partial-failed]').exists()).toBe(false) expect(wrapper.element.nodeType).toBe(Node.COMMENT_NODE) }) it.each([ ['a 500', { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } }], ['a 404', { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } }], ['a 403', { status: 403, body: {} }], ])('shows the failure box on %s', async (_, reply) => { mockApi({ [`GET ${STATS}`]: reply }) const wrapper = mountHost() await flushPromises() const box = wrapper.find('[data-partial-failed]') expect(box.attributes('role')).toBe('alert') expect(box.text()).toBe(PARTIAL_FAILED) expect(wrapper.findComponent(ExtensionFailure).exists()).toBe(true) }) it('shows the failure box when the request cannot be sent', async () => { vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down')) const wrapper = mountHost() await flushPromises() expect(wrapper.find('[data-partial-failed]').text()).toBe(PARTIAL_FAILED) }) it('keeps the previous nodes with aria-busy while a reload runs', async () => { const later = deferred() let gets = 0 const calls = mockApi({ [`GET ${STATS}`]: () => (++gets === 1 ? nodesReply([{ tag: 'p', children: [{ text: 'First' }] }]) : later.route()) }) const wrapper = mountHost() await flushPromises() expect(wrapper.find('[data-partial="header"]').text()).toBe('First') await wrapper.setProps({ reloadKey: 1 }) await flushPromises() expect(requestsTo(calls, 'GET', STATS)).toHaveLength(2) const host = wrapper.find('[data-partial="header"]') expect(host.attributes('aria-busy')).toBe('true') expect(host.text()).toBe('First') expect(wrapper.find('[data-partial-skeleton]').exists()).toBe(false) later.release(nodesReply([{ tag: 'p', children: [{ text: 'Second' }] }])) await flushPromises() expect(wrapper.find('[data-partial="header"]').attributes('aria-busy')).toBeUndefined() expect(wrapper.find('[data-partial="header"]').text()).toBe('Second') }) it('keeps only the latest answer when reloads overlap', async () => { const first = deferred() const second = deferred() let gets = 0 mockApi({ [`GET ${STATS}`]: () => (++gets === 1 ? first.route() : second.route()) }) const wrapper = mountHost() await wrapper.setProps({ reloadKey: 1 }) second.release(nodesReply([{ text: 'newest' }])) await flushPromises() first.release(nodesReply([{ text: 'stale' }])) await flushPromises() expect(wrapper.text()).toBe('newest') }) it('sends ?id= only with a record', async () => { const calls = mockApi({ [`GET ${STATS}`]: nodesReply([{ text: 'x' }]) }) mountHost({ variant: 'field' }) mountHost({ variant: 'field', recordId: null }) mountHost({ variant: 'field', recordId: 42 }) await flushPromises() const queries = requestsTo(calls, 'GET', STATS).map((request) => queryOf(request)) expect(queries.map((query) => query.get('id'))).toEqual([null, null, '42']) expect(queries.map((query) => [...query.keys()].length)).toEqual([0, 0, 1]) }) })