160 lines
13 KiB
TypeScript
160 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, expect, it } from 'vitest';
|
|
import { importFile } from '../../src/importers/importer';
|
|
import { importSource } from '../../src/importers/engine';
|
|
import { importProfiles } from '../../src/profiles/profile-catalog';
|
|
import { nodeImportRuntimeAdapters } from '../../server/node-import-runtime-adapters';
|
|
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
|
|
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
|
|
import { assertCanonicalAssessmentModel } from '../../src/domain/canonical-validation';
|
|
import { decodeHtmlDocument, decodeHtmlLexically, markupText, sanitizeHtmlText } from '../../src/importers/html-extraction';
|
|
import { protectHtmlAttributeMarkup } from '../../src/importers/html-attribute-markup';
|
|
|
|
const html = (body: string) => `<html><title>PingCastle Healthcheck</title><body>${body}</body></html>`;
|
|
const heading = (matched: string, total = '5') => `<h2 class="indicator-header">Stale Objects rule details [${matched} rules matched on a total of ${total}]</h2>`;
|
|
const rule = (id: string, extra = '') => `<article><h3>Synthetic finding</h3><strong>Rule ID:</strong><p>${id}</p>${extra}</article>`;
|
|
const browserImport = (text: string, extension: string) => importFile(new File([text], `ad_hc_synthetic.${extension}`), { profiles: importProfiles });
|
|
function serverImport(text: string, extension: string) {
|
|
const bytes = new TextEncoder().encode(text);
|
|
return importSource({ metadata: { name: `ad_hc_synthetic.${extension}`, sizeBytes: bytes.length, lastModified: 0 }, bytes }, { profiles: importProfiles }, nodeImportRuntimeAdapters);
|
|
}
|
|
async function both(text: string, extension: string) {
|
|
return [await browserImport(text, extension), await serverImport(text, extension)];
|
|
}
|
|
function canonical(dataset: Awaited<ReturnType<typeof browserImport>>) {
|
|
const preview = previewEntryFromDataset(dataset, { fileName: dataset.fileName, sha256: 'a'.repeat(64), sizeBytes: dataset.fileSize, lastModified: 0 });
|
|
const model = createCanonicalImportBatch('pingcastle-integrity', [confirmEntryMappings(preview, importProfiles)], '2026-09-09T12:00:00Z').model;
|
|
assertCanonicalAssessmentModel(model);
|
|
return model;
|
|
}
|
|
|
|
describe('PingCastle context and finding integrity', () => {
|
|
it('preserves text, every exact XML attribute, namespaced attributes and explicit empty leaves in both runtimes', async () => {
|
|
const source = `<HealthcheckData xmlns:test="urn:synthetic"><GlobalScore>0</GlobalScore><RiskRules/>
|
|
<EmptyCollection/><Text flag="false" quote="a "; b < c & d">Synthetic</Text>
|
|
<Rights><Right test:allow="false" Count="0" blank="" Name="__proto__" __proto__="retained" /></Rights>
|
|
<CData><![CDATA[Synthetic <literal>]]></CData></HealthcheckData>`;
|
|
const results = await both(source, 'xml');
|
|
expect(results[0].extraction).toEqual(results[1].extraction);
|
|
for (const dataset of results) {
|
|
const model = canonical(dataset);
|
|
const observations = model.environmentObservations ?? [];
|
|
expect(model.findingOccurrences).toHaveLength(0);
|
|
expect(observations).toContainEqual(expect.objectContaining({ originalValue: null, state: 'empty', source: expect.objectContaining({ objectPath: 'HealthcheckData/EmptyCollection[3]' }) }));
|
|
expect(observations).toContainEqual(expect.objectContaining({ originalValue: '0', normalizedValue: 0, state: 'observed', source: expect.objectContaining({ objectPath: 'HealthcheckData/Rights[5]/Right[1]/@Count' }) }));
|
|
expect(observations).toContainEqual(expect.objectContaining({ originalValue: 'false', normalizedValue: false, source: expect.objectContaining({ objectPath: 'HealthcheckData/Rights[5]/Right[1]/@test:allow' }) }));
|
|
expect(observations).toContainEqual(expect.objectContaining({ originalValue: '', state: 'empty', source: expect.objectContaining({ objectPath: 'HealthcheckData/Rights[5]/Right[1]/@blank' }) }));
|
|
expect(observations).toContainEqual(expect.objectContaining({ originalValue: 'a "; b < c & d', source: expect.objectContaining({ objectPath: 'HealthcheckData/Text[4]/@quote' }) }));
|
|
expect(observations).toContainEqual(expect.objectContaining({ originalValue: 'retained', source: expect.objectContaining({ objectPath: 'HealthcheckData/Rights[5]/Right[1]/@__proto__' }) }));
|
|
expect(observations).toContainEqual(expect.objectContaining({ originalValue: 'Synthetic <literal>' }));
|
|
expect(observations.some(item => /RiskRules/.test(item.source.objectPath ?? ''))).toBe(false);
|
|
expect(model.reportSections?.[0].tables[0].rows).toHaveLength(observations.length);
|
|
expect(dataset.rawText).toBe(source);
|
|
}
|
|
});
|
|
|
|
it.each(['html', 'xml'])('rejects a missing native ID in %s instead of discarding a rule', async extension => {
|
|
const source = extension === 'html' ? html(heading('1') + rule('')) : '<HealthcheckData><RiskRules><HealthcheckRiskRule><Points>10</Points></HealthcheckRiskRule></RiskRules></HealthcheckData>';
|
|
for (const run of [browserImport, serverImport]) await expect(run(source, extension)).rejects.toMatchObject({ diagnostics: expect.arrayContaining([expect.objectContaining({ code: 'pingcastle-rule-id-missing', severity: 'error' })]) });
|
|
});
|
|
|
|
it.each(['html', 'xml'])('diagnoses duplicate IDs in %s without changing the existing first-presentation behavior', async extension => {
|
|
const source = extension === 'html' ? html(heading('1') + rule('S-Synthetic') + rule('S-Synthetic')) : '<HealthcheckData><RiskRules><HealthcheckRiskRule><RiskId>S-Synthetic</RiskId></HealthcheckRiskRule><HealthcheckRiskRule><RiskId>S-Synthetic</RiskId></HealthcheckRiskRule></RiskRules></HealthcheckData>';
|
|
for (const dataset of await both(source, extension)) {
|
|
expect(dataset.extraction?.candidates).toHaveLength(1);
|
|
expect(dataset.extraction?.processing.status).toBe('partial');
|
|
expect(dataset.extraction?.diagnostics).toContainEqual(expect.objectContaining({ code: 'pingcastle-duplicate-rule-id', source: expect.objectContaining({ objectPath: expect.any(String) }) }));
|
|
}
|
|
});
|
|
|
|
it('warns on mismatched totals, preserving all valid rules and the original declaration', async () => {
|
|
for (const dataset of await both(html(heading('1') + rule('S-One') + rule('S-Two')), 'html')) {
|
|
expect(dataset.extraction?.candidates).toHaveLength(2);
|
|
expect(dataset.extraction?.diagnostics).toContainEqual(expect.objectContaining({ code: 'pingcastle-rule-count-mismatch', details: { category: 'Stale Objects', declared: 1, actual: 2 } }));
|
|
const model = canonical(dataset);
|
|
expect(model.findingOccurrences).toHaveLength(2);
|
|
expect(model.reportSections?.find(section => section.sectionKey === 'pingcastle-rule-coverage')?.tables[0].rows[0].slice(1, 4)).toEqual(['1', '5', 2]);
|
|
}
|
|
});
|
|
|
|
it('accepts an explicitly empty report but rejects an announced nonempty report without results', async () => {
|
|
for (const dataset of await both(html(heading('0')), 'html')) {
|
|
expect(dataset.extraction?.candidates).toHaveLength(0);
|
|
expect(dataset.extraction?.processing.status).toBe('complete');
|
|
}
|
|
for (const run of [browserImport, serverImport]) await expect(run(html(heading('2')), 'html')).rejects.toMatchObject({ diagnostics: expect.arrayContaining([expect.objectContaining({ code: 'pingcastle-rule-results-missing' })]) });
|
|
});
|
|
|
|
it.each(['NaN', '-1', '9007199254740992', '6'])('diagnoses an invalid declared count %s', async count => {
|
|
const dataset = await browserImport(html(heading(count) + rule('S-Synthetic')), 'html');
|
|
expect(dataset.extraction?.diagnostics).toContainEqual(expect.objectContaining({ code: 'pingcastle-rule-count-invalid' }));
|
|
});
|
|
|
|
it('keeps categories independent and does not confuse the catalog size with matched results', async () => {
|
|
const body = heading('1', '56') + rule('S-One') + '<h2 class="indicator-header">Anomalies rule details [0 rules matched on a total of 73]</h2>';
|
|
const dataset = await browserImport(html(body), 'html');
|
|
expect(dataset.extraction?.diagnostics).toEqual([]);
|
|
expect(dataset.extraction?.candidates).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps tooltip markup out of contextual cells, direct finding details and linked detail tables', async () => {
|
|
const table = `<table><tr><th>Name</th><th>Count</th></tr><tr><td data-bs-content="<p>Tooltip > content</p>" class="technical">Synthetic value</td><td title='<b>Hidden</b>'>0</td></tr></table>`;
|
|
const source = html(heading('1') + rule('S-Synthetic', '<strong>Details:</strong><p>Synthetic details</p>' + table) + '<div id="sectionDomainInformation"><h1>Domain Information</h1>' + table + '</div>');
|
|
for (const dataset of await both(source, 'html')) {
|
|
const model = canonical(dataset);
|
|
const context = model.reportSections?.find(section => section.sectionKey === 'sectionDomainInformation')!;
|
|
expect(context.tables[0].rows).toEqual([['Synthetic value', '0']]);
|
|
const candidate = dataset.extraction!.candidates[0];
|
|
expect(candidate.fields.find(field => field.label === 'Details')?.decodedValue).toBe('Synthetic details\nName | Count\nSynthetic value | 0');
|
|
expect(candidate.fields.find(field => field.key === 'linked-section')?.originalValue).toMatchObject({ sections: [{ rows: [['Synthetic value', '0']] }] });
|
|
expect(JSON.stringify(model.environmentObservations)).not.toMatch(/Tooltip|Hidden|data-bs-content|technical/);
|
|
expect(dataset.rawText).toBe(source);
|
|
}
|
|
});
|
|
it('keeps later report sections when a bounded metadata excerpt ends within an attribute', async () => {
|
|
const source = html(`<div>${'x'.repeat(499_900)}</div><span title="${'y'.repeat(400)}<p>Hidden</p>">Visible</span>`
|
|
+ heading('1') + rule('S-Synthetic') + '<div id="sectionDomainInformation"><h1>Domain Information</h1><table><tr><th>Value</th></tr><tr><td>Synthetic</td></tr></table></div>');
|
|
for (const dataset of await both(source, 'html')) {
|
|
expect(dataset.extraction?.candidates).toHaveLength(1);
|
|
expect(dataset.extraction?.reportSections?.find(section => section.sectionKey === 'sectionDomainInformation')?.tables[0].rows).toEqual([['Synthetic']]);
|
|
expect(dataset.rawText).toBe(source);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('bounded quoted-attribute markup handling', () => {
|
|
it.each(['"', "'"])('agrees with the DOM parser for %s-quoted tooltips', quote => {
|
|
const source = `<table><tr><th>Value</th></tr><tr><td data-tooltip=${quote}<p>not <b>visible</b></p>${quote} rowspan="1">42 & safe</td></tr></table>`;
|
|
const document = new DOMParser().parseFromString(source, 'text/html');
|
|
// The DOM path deliberately keeps safe presentation markup; compare visible values.
|
|
expect(decodeHtmlLexically(source).tables.map(table => table.rows)).toEqual(decodeHtmlDocument(document).tables.map(table => table.rows.map(row => row.map(markupText))));
|
|
expect(markupText(source)).toBe('Value 42 & safe');
|
|
});
|
|
it('retains values, entities and raw script strings without interpreting them', () => {
|
|
const source = '<script>const text = `<p title="unterminated>`;</script><p title="a < b > c < d">Safe</p>';
|
|
const protectedText = protectHtmlAttributeMarkup(source);
|
|
expect(protectedText).toContain('const text = `<p title="unterminated>`;');
|
|
expect(protectedText).toContain('title="a < b > c < d"');
|
|
expect(markupText(sanitizeHtmlText(source).text)).toBe('Safe');
|
|
expect(protectHtmlAttributeMarkup(protectedText)).toBe(protectedText);
|
|
});
|
|
it('rejects unterminated quoted attributes explicitly', () => {
|
|
expect(() => protectHtmlAttributeMarkup('<table><td title="<p>bad</p>>')).toThrow(/Attribut/);
|
|
});
|
|
it('ignores a final partial tag in a bounded text fragment', () => {
|
|
expect(markupText('<p title="<b>hidden</b>">Visible</p><i title="<p>truncated')).toBe('Visible');
|
|
});
|
|
it('does not open a quoted value on an apostrophe in an unquoted value or malformed attribute name', () => {
|
|
const source = `<p data-label=it's title="<b>hidden</b>">Visible</p><p malformed'attr>Next</p>`;
|
|
expect(markupText(source)).toBe('Visible Next');
|
|
expect(protectHtmlAttributeMarkup(source)).toContain(`data-label=it's title="<b>hidden</b>"`);
|
|
});
|
|
it('does not sanitize attribute-like strings inside other attributes or visible prose', () => {
|
|
const source = `<p title="<img src='hidden' onload='hidden()'><a href='javascript:hidden()'>hidden</a>" onclick="active()">Visible src='text'</p><a href="javascript:active()">Link</a>`;
|
|
const sanitized = sanitizeHtmlText(source);
|
|
expect(sanitized.removedContentCount).toBe(2);
|
|
expect(markupText(sanitized.text)).toBe("Visible src='text' Link");
|
|
expect(protectHtmlAttributeMarkup(sanitized.text)).toBe(sanitized.text);
|
|
});
|
|
});
|