fix: reject native-hidden semantic evidence
This commit is contained in:
@@ -145,13 +145,19 @@ const excludedSemanticContainers = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSC
|
||||
|
||||
const isVisibleSemanticNode = (node, appRoot) => {
|
||||
if (!node.isConnected || !appRoot.contains(node)) return false;
|
||||
let element = node.nodeType === 1 ? node : node.parentElement;
|
||||
const originElement = node.nodeType === 1 ? node : node.parentElement;
|
||||
let element = originElement;
|
||||
while (element !== null) {
|
||||
if (excludedSemanticContainers.has(element.tagName) || element.hasAttribute('hidden') ||
|
||||
element.hasAttribute('inert') || element.getAttribute('aria-hidden') === 'true' ||
|
||||
element.hasAttribute('inert') || element.getAttribute('aria-hidden')?.toLowerCase() === 'true' ||
|
||||
(element.tagName === 'DIALOG' && !element.hasAttribute('open')) ||
|
||||
element.style?.display === 'none' || element.style?.visibility === 'hidden') {
|
||||
return false;
|
||||
}
|
||||
if (element.tagName === 'DETAILS' && !element.hasAttribute('open') && originElement !== element) {
|
||||
const summary = [...element.children].find((child) => child.tagName === 'SUMMARY');
|
||||
if (summary === undefined || (originElement !== summary && !summary.contains(originElement))) return false;
|
||||
}
|
||||
if (element === appRoot) return true;
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,14 @@ const clientJavaScript = 'export {};\n';
|
||||
const clientStylesheet = 'body { margin: 0; }\n';
|
||||
const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
const wrapAppContents = (html: string, opening: string, closing: string): string => {
|
||||
const appOpening = '<div id="app">';
|
||||
const appContentStart = html.indexOf(appOpening) + appOpening.length;
|
||||
const snapshotStart = html.indexOf('<script id="portfolio-inputs"');
|
||||
const appContentEnd = html.lastIndexOf('</div>', snapshotStart);
|
||||
return `${html.slice(0, appContentStart)}${opening}${html.slice(appContentStart, appContentEnd)}${closing}${html.slice(appContentEnd)}`;
|
||||
};
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
@@ -285,10 +293,55 @@ describe('artifact verification', () => {
|
||||
return `${html.slice(0, snapshotAt).replaceAll('Secure self-hosted platform and lifecycle', 'Removed evidence')}${html.slice(snapshotAt)}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'all semantic SSR inside a closed dialog',
|
||||
mutate: (html: string) => wrapAppContents(html, '<dialog>', '</dialog>'),
|
||||
},
|
||||
{
|
||||
name: 'required evidence outside the summary of closed details',
|
||||
mutate: (html: string, inputs: Readonly<ProductionInputs>) => {
|
||||
const evidence = inputs.content.infrastructure.bands[1]!.summary;
|
||||
return html.replace(evidence, `<details><summary>Infrastructure evidence</summary><span>${evidence}</span></details>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mixed-case true aria-hidden navigation',
|
||||
mutate: (html: string) => html.replace(
|
||||
'<nav class="site-navigation"',
|
||||
'<nav aria-hidden="TRUE" class="site-navigation"',
|
||||
),
|
||||
},
|
||||
])('rejects $name as non-visible semantic evidence', async ({ mutate }) => {
|
||||
const { directory, html, inputs, contract } = await writeValidArtifacts();
|
||||
await writeFile(join(directory, 'index.html'), mutate(html));
|
||||
await writeFile(join(directory, 'index.html'), mutate(html, inputs));
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/visible semantic|visible h1|visible navigation|visible section|connected/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'semantic SSR inside an open dialog',
|
||||
mutate: (html: string, _inputs: Readonly<ProductionInputs>) =>
|
||||
wrapAppContents(html, '<dialog open>', '</dialog>'),
|
||||
},
|
||||
{
|
||||
name: 'required evidence inside open details',
|
||||
mutate: (html: string, inputs: Readonly<ProductionInputs>) => {
|
||||
const evidence = inputs.content.infrastructure.bands[1]!.summary;
|
||||
return html.replace(evidence, `<details open><summary>Infrastructure evidence</summary><span>${evidence}</span></details>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'required evidence inside the active summary of closed details',
|
||||
mutate: (html: string, inputs: Readonly<ProductionInputs>) => {
|
||||
const evidence = inputs.content.infrastructure.bands[1]!.summary;
|
||||
return html.replace(evidence, `<details><summary>${evidence}</summary><span>Collapsed evidence</span></details>`);
|
||||
},
|
||||
},
|
||||
])('accepts $name as visible semantic evidence', async ({ mutate }) => {
|
||||
const { directory, html, inputs, contract } = await writeValidArtifacts();
|
||||
await writeFile(join(directory, 'index.html'), mutate(html, inputs));
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user