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) => {
|
const isVisibleSemanticNode = (node, appRoot) => {
|
||||||
if (!node.isConnected || !appRoot.contains(node)) return false;
|
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) {
|
while (element !== null) {
|
||||||
if (excludedSemanticContainers.has(element.tagName) || element.hasAttribute('hidden') ||
|
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') {
|
element.style?.display === 'none' || element.style?.visibility === 'hidden') {
|
||||||
return false;
|
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;
|
if (element === appRoot) return true;
|
||||||
element = element.parentElement;
|
element = element.parentElement;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ const clientJavaScript = 'export {};\n';
|
|||||||
const clientStylesheet = 'body { margin: 0; }\n';
|
const clientStylesheet = 'body { margin: 0; }\n';
|
||||||
const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
|
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[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
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)}`;
|
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 }) => {
|
])('rejects $name as non-visible semantic evidence', async ({ mutate }) => {
|
||||||
const { directory, html, inputs, contract } = await writeValidArtifacts();
|
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);
|
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