378 lines
22 KiB
TypeScript
378 lines
22 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import pingCastleHtml from '../../fixtures/pingcastle/healthcheck.synthetic.html?raw';
|
|
import pingCastleXml from '../../fixtures/pingcastle/healthcheck.synthetic.xml?raw';
|
|
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
|
|
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
|
|
import { importFile } from '../../src/importers/importer';
|
|
import { importProfiles } from '../../src/profiles/profile-catalog';
|
|
|
|
async function importPingCastle(content: string, fileName: string, type: string) {
|
|
return importFile(new File([content], fileName, { type }), { profiles: importProfiles });
|
|
}
|
|
|
|
describe('PingCastle native finding extraction', () => {
|
|
it('captures non-finding environment sections, typed facts and explicit absence in the canonical model', async () => {
|
|
const environmentSections = [
|
|
'<p>Date: 2026-08-20 - Engine version: 3.4.0</p>',
|
|
'<div id="sectionDomainInformation"><h2>Domain Information</h2>',
|
|
'<p>No Entra ID configuration has been found in this domain.</p>',
|
|
'<table><caption>Directory</caption><thead><tr>',
|
|
'<th>Domain</th><th>Domain Functional Level</th><th>Forest Functional Level</th><th>Schema Version</th><th>Recycle Bin Enabled</th><th>Number of DC</th>',
|
|
'</tr></thead><tbody><tr><td>synthetic.example</td><td>2016</td><td>2016</td><td>88</td><td>Yes</td><td>2</td></tr></tbody></table></div>',
|
|
'<div id="sectionPKI"><h2>Public Key Infrastructure</h2><table><caption>Certificate Authorities</caption>',
|
|
'<thead><tr><th>Certificate Authority</th></tr></thead><tbody></tbody></table></div>',
|
|
'<div id="sectionInfrastructure"><h2>Infrastructure</h2><p>Number of WEF configuration found: 0</p></div>',
|
|
'<div id="sectionLAPS"><h2>LAPS</h2><p>Legacy LAPS installation date: Never Windows LAPS installation date: Never</p></div>',
|
|
'<div id="sectionMaturity"><h2>Maturity</h2><p>This section is reserved for customers who have purchased a license.</p></div>'
|
|
].join('');
|
|
const content = pingCastleHtml.replace('</main>', environmentSections + '</main>');
|
|
const dataset = await importPingCastle(content, 'ad_hc_environment.synthetic.html', 'text/html');
|
|
|
|
expect(dataset.extraction?.reportSections).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ sectionKey: 'sectionDomainInformation', state: 'observed' }),
|
|
expect.objectContaining({ sectionKey: 'sectionPKI', state: 'empty' }),
|
|
expect.objectContaining({ sectionKey: 'sectionMaturity', state: 'license-restricted' })
|
|
]));
|
|
expect(dataset.extraction?.environmentObservations).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ property: 'domain.fqdn', normalizedValue: 'synthetic.example' }),
|
|
expect.objectContaining({ property: 'domain.functional_level', normalizedValue: 2016 }),
|
|
expect.objectContaining({ property: 'forest.functional_level', normalizedValue: 2016 }),
|
|
expect.objectContaining({ property: 'domain.recycle_bin_enabled', normalizedValue: true }),
|
|
expect.objectContaining({ property: 'domain.controller_count', normalizedValue: 2 }),
|
|
expect.objectContaining({ property: 'entra.configuration_detected', state: 'explicitly-absent' }),
|
|
expect.objectContaining({ property: 'logging.wef.configuration_count', normalizedValue: 0 }),
|
|
expect.objectContaining({ property: 'laps.legacy.installation_date', originalValue: 'Never', state: 'explicitly-absent' }),
|
|
expect.objectContaining({ property: 'report.engine_version', normalizedValue: '3.4.0' })
|
|
]));
|
|
|
|
const preview = previewEntryFromDataset(dataset, {
|
|
sha256: 'environment'.padEnd(64, '0'), fileName: dataset.fileName, sizeBytes: content.length, lastModified: 0
|
|
});
|
|
const entry = confirmEntryMappings(preview, importProfiles);
|
|
const model = createCanonicalImportBatch('environment-batch', [entry], '2026-08-20T12:00:00.000Z').model;
|
|
expect(model.reportSections).toHaveLength(dataset.extraction?.reportSections?.length ?? 0);
|
|
expect(model.environmentObservations).toHaveLength(dataset.extraction?.environmentObservations?.length ?? 0);
|
|
expect(model.importFiles[0].processedWith).toMatchObject({
|
|
profileId: 'pingcastle', profileVersion: '1.3.0', mappingVersion: '1.1.0',
|
|
compatibilityKey: 'pingcastle-canonical-v1.1'
|
|
});
|
|
expect(model.scanRuns[0].tool.version).toBe('3.4.0');
|
|
});
|
|
|
|
it('maps the same matched rule identity from HTML and XML while preserving format-native provenance', async () => {
|
|
const datasets = await Promise.all([
|
|
importPingCastle(pingCastleHtml, 'ad_hc_synthetic.example.html', 'text/html'),
|
|
importPingCastle(pingCastleXml, 'ad_hc_synthetic.example.xml', 'application/xml')
|
|
]);
|
|
|
|
expect(datasets[0].intermediate?.diagnostics).not.toContainEqual(expect.objectContaining({
|
|
code: 'pingcastle-xml-results-unavailable'
|
|
}));
|
|
expect(datasets[1].intermediate?.diagnostics).toContainEqual(expect.objectContaining({
|
|
code: 'pingcastle-xml-results-unavailable',
|
|
severity: 'warning'
|
|
}));
|
|
|
|
const models = datasets.map((dataset, index) => {
|
|
expect(dataset.profileId).toBe('pingcastle');
|
|
expect(dataset.recognition?.source.selected.confidence.level).toBe('high');
|
|
expect(dataset.extraction?.extractor.id).toBe('pingcastle-healthcheck-source-extractor');
|
|
expect(dataset.mapping?.candidates).toHaveLength(1);
|
|
const candidate = dataset.mapping!.candidates[0];
|
|
expect(candidate.source.tableIndex).toBeUndefined();
|
|
expect(candidate.source.objectPath).toMatch(index === 0 ? /^html\/rules\// : /RiskRules/);
|
|
expect(candidate.fields.find(field => field.role === 'rule-id')?.decodedValue).toBe('S-Synthetic');
|
|
expect(candidate.fields.find(field => field.role === 'title')?.decodedValue)
|
|
.toBe('Synthetic inactive privileged account');
|
|
expect(candidate.fields.find(field => field.label === 'Domain Risk Level')?.decodedValue).toBe('20');
|
|
expect(candidate.fields.find(field => field.label === 'Category Score')?.decodedValue, index === 0 ? 'HTML' : 'XML').toBe('20');
|
|
// "Points" muss die tatsaechlich vergebene Punktzahl tragen (aus dem Card-Header-Badge
|
|
// "+ N Point(s)" bei HTML, aus <Points> bei XML), nicht das beschreibende Punkteschema.
|
|
expect(candidate.fields.find(field => field.label === 'Points')?.decodedValue).toBe('20');
|
|
if (index === 0) {
|
|
expect(candidate.fields.find(field => field.label === 'Points Scale')?.decodedValue)
|
|
.toBe('25 points if the occurence is greater than or equals than 25 then 20 points if present');
|
|
}
|
|
if (index === 1) {
|
|
expect(candidate.fields.find(field => field.label === 'Details')?.decodedValue).toBe([
|
|
'Synthetic detail without tenant identifiers.',
|
|
'Synthetic setting: Enabled',
|
|
'Synthetic values: Alpha, Beta'
|
|
].join('\n'));
|
|
}
|
|
|
|
const preview = previewEntryFromDataset(dataset, {
|
|
sha256: ('pingcastle-format-' + index).padEnd(64, '0').slice(0, 64),
|
|
fileName: dataset.fileName,
|
|
sizeBytes: index === 0 ? pingCastleHtml.length : pingCastleXml.length,
|
|
lastModified: 0
|
|
});
|
|
const entry = confirmEntryMappings(preview, importProfiles);
|
|
expect(entry.recordCount).toBe(1);
|
|
expect(entry.mappingsToConfirm).toBe(0);
|
|
return createCanonicalImportBatch(
|
|
'pingcastle-format-' + index,
|
|
[entry],
|
|
'2026-07-22T12:00:00.000Z'
|
|
).model;
|
|
});
|
|
|
|
// Eine getroffene RiskRule ist eine erkannte Exposition und damit ein
|
|
// fachliches fail - Voraussetzung dafuer, dass der Befund als is_ioe gilt.
|
|
expect(models.map(model => ({
|
|
canonicalKey: model.findingDefinitions[0].canonicalKey,
|
|
title: model.findingDefinitions[0].title,
|
|
ruleId: model.findingOccurrences[0].sourceIdentity.canonicalRuleId,
|
|
status: model.findingOccurrences[0].status.normalizedValue,
|
|
severity: model.findingOccurrences[0].severity.normalizedValue
|
|
}))).toEqual([
|
|
{
|
|
canonicalKey: 'pingcastle:s-synthetic',
|
|
title: 'Synthetic inactive privileged account',
|
|
ruleId: 'S-Synthetic',
|
|
status: 'failed',
|
|
severity: 'medium'
|
|
},
|
|
{
|
|
canonicalKey: 'pingcastle:s-synthetic',
|
|
title: 'Synthetic inactive privileged account',
|
|
ruleId: 'S-Synthetic',
|
|
status: 'failed',
|
|
severity: 'medium'
|
|
}
|
|
]);
|
|
expect(models[0].findingOccurrences[0].sourcePosition.objectPath).toMatch(/^html\/rules\//);
|
|
expect(models[1].findingOccurrences[0].sourcePosition.objectPath).toMatch(/RiskRules/);
|
|
models.forEach(model => {
|
|
expect(model.findingOccurrences[0].severity.originalValue).toBe(20);
|
|
expect(model.findingOccurrences[0].severity.provenance.decisionSource).toBe('automatic');
|
|
expect(model.findingOccurrences[0].severity.provenance.confidence.reasons[0])
|
|
.toMatch(/pingcastle-severity-from-points@1\.0\.0/);
|
|
});
|
|
});
|
|
|
|
it('deduplicates repeated report presentations and ignores rule-shaped active HTML content', async () => {
|
|
const repeatedRule = [
|
|
'<article><h3>Duplicate rendering must not replace the first title</h3>',
|
|
'<p><strong>Rule ID:</strong> S-Synthetic</p>',
|
|
'<p><strong>Description:</strong> Duplicate presentation.</p>',
|
|
'<p><strong>Points:</strong> 20</p></article>'
|
|
].join('');
|
|
const activeContent = '<script><strong>Rule ID:</strong><p>ACTIVE-CONTENT</p></script>';
|
|
const content = pingCastleHtml.replace('</main>', repeatedRule + activeContent + '</main>');
|
|
|
|
const dataset = await importPingCastle(content, 'ad_hc_duplicate.synthetic.html', 'text/html');
|
|
|
|
expect(dataset.mapping?.candidates).toHaveLength(1);
|
|
expect(dataset.mapping?.candidates[0].fields.find(field => field.role === 'rule-id')?.decodedValue)
|
|
.toBe('S-Synthetic');
|
|
expect(dataset.mapping?.candidates[0].fields.find(field => field.role === 'title')?.decodedValue)
|
|
.toBe('Synthetic inactive privileged account');
|
|
expect(dataset.intermediate?.diagnostics).toContainEqual(expect.objectContaining({
|
|
code: 'html-active-content-removed'
|
|
}));
|
|
});
|
|
|
|
it('captures an evidence table PingCastle renders directly after "Details:" instead of dropping it', async () => {
|
|
const detailTableRule = [
|
|
'<article><h3>Synthetic detail table finding</h3>',
|
|
'<p><strong>Rule ID:</strong> S-DetailTable</p>',
|
|
'<p><strong>Description:</strong> Synthetic finding with an inline evidence table.</p>',
|
|
'<strong>Details:</strong><p>The detail can be found in the Synthetic User List</p><div class="row">',
|
|
'<div class="col-md-12 table-responsive"><table class="table"><thead><tr><th>DN</th></tr></thead>',
|
|
'<tbody><tr><td>CN=Synthetic User,DC=example,DC=test</td></tr></tbody></table></div></div>',
|
|
'<p><strong>Points:</strong> 10</p></article>'
|
|
].join('');
|
|
const content = pingCastleHtml.replace('</main>', detailTableRule + '</main>');
|
|
|
|
const dataset = await importPingCastle(content, 'ad_hc_detail_table.synthetic.html', 'text/html');
|
|
|
|
const candidate = dataset.mapping?.candidates.find(
|
|
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-DetailTable'
|
|
);
|
|
expect(candidate?.fields.find(field => field.label === 'Details')?.decodedValue).toBe(
|
|
'The detail can be found in the Synthetic User List\nDN\nCN=Synthetic User,DC=example,DC=test'
|
|
);
|
|
});
|
|
|
|
it('reads 0 awarded points from the "Informative rule" card-header badge instead of leaving Points empty', async () => {
|
|
const informativeRule = [
|
|
'<div class="card"><div class="card-header" id="headingrulesS-Informative">',
|
|
'<span class="card-title mb-0"><button class="btn btn-link" data-bs-toggle="collapse" data-bs-target="#rulesS-Informative">Synthetic informative rule</button></span>',
|
|
"<i class=\"float-end\"><span class='float-end'>Informative rule</span></i></div>",
|
|
'<div id="rulesS-Informative" class="collapse"><div class="card-body risk-card"><article>',
|
|
'<h3>Synthetic informative rule</h3>',
|
|
'<p><strong>Rule ID:</strong> S-Informative</p>',
|
|
'<p><strong>Description:</strong> Synthetic informative finding without risk points.</p>',
|
|
'<p><strong>Points:</strong> 0 points if present</p>',
|
|
'</article></div></div></div>'
|
|
].join('');
|
|
const content = pingCastleHtml.replace('</main>', informativeRule + '</main>');
|
|
|
|
const dataset = await importPingCastle(content, 'ad_hc_informative.synthetic.html', 'text/html');
|
|
|
|
const candidate = dataset.mapping?.candidates.find(
|
|
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-Informative'
|
|
);
|
|
expect(candidate?.fields.find(field => field.label === 'Points')?.decodedValue).toBe('0');
|
|
});
|
|
|
|
it('follows an internal "Details:" anchor link to its target subsection instead of leaving only the reference sentence', async () => {
|
|
const ruleWithLink = [
|
|
'<article><h3>Synthetic rule referencing another section</h3>',
|
|
'<p><strong>Rule ID:</strong> S-AnchorLink</p>',
|
|
'<p><strong>Description:</strong> Synthetic finding whose detail lives in another report section.</p>',
|
|
'<strong>Details:</strong><p>The detail can be found in <a href="#syntheticsection">Synthetic Section</a></p>',
|
|
'<p><strong>Points:</strong> 10 points if present</p></article>'
|
|
].join('');
|
|
const targetSection = [
|
|
'<!-- SubSection Synthetic Section --><a name="syntheticsection"></a>',
|
|
'<div class="row"><div class="col-lg-12 mt-2"><h2 class="sub-section">Synthetic Section</h2></div></div>',
|
|
'<!-- SubSection Synthetic Section end -->',
|
|
'<div class="row"><div class="col-lg-12"><p>Synthetic resolved paragraph without tenant identifiers.</p>',
|
|
'<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Synthetic Entry</td></tr></tbody></table>',
|
|
'</div></div>',
|
|
'<!-- SubSection Next Section -->',
|
|
'<div class="row"><div class="col-lg-12"><p>This must not leak into the resolved section above.</p></div></div>'
|
|
].join('');
|
|
const content = pingCastleHtml.replace('</main>', ruleWithLink + targetSection + '</main>');
|
|
|
|
const dataset = await importPingCastle(content, 'ad_hc_anchor_link.synthetic.html', 'text/html');
|
|
|
|
const candidate = dataset.mapping?.candidates.find(
|
|
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-AnchorLink'
|
|
);
|
|
const details = candidate?.fields.find(field => field.label === 'Details')?.decodedValue;
|
|
expect(details).toContain('Synthetic resolved paragraph without tenant identifiers.');
|
|
expect(details).toContain('Synthetic Entry');
|
|
expect(details).not.toContain('must not leak');
|
|
expect(details).not.toContain('href');
|
|
});
|
|
|
|
it('captures the embedded evidence table AND the linked target section for the same rule', async () => {
|
|
const ruleWithBoth = [
|
|
'<article><h3>Synthetic rule with embedded table and link</h3>',
|
|
'<p><strong>Rule ID:</strong> S-Both</p>',
|
|
'<strong>Details:</strong><p>The detail can be found in <a href="#syntheticsection">Synthetic Section</a></p><div class="row">',
|
|
'<div class="col-md-12 table-responsive"><table class="table"><thead><tr><th>Domain controller</th></tr></thead>',
|
|
'<tbody><tr><td>SYNTHETIC-DC</td></tr></tbody></table></div></div>',
|
|
'<p><strong>Points:</strong> 10 points if present</p></article>'
|
|
].join('');
|
|
const targetSection = [
|
|
'<!-- SubSection Synthetic Section --><a name="syntheticsection"></a>',
|
|
'<div class="row"><div class="col-lg-12 mt-2"><h2 class="sub-section">Synthetic Section</h2></div></div>',
|
|
'<!-- SubSection Synthetic Section end -->',
|
|
'<div class="row"><div class="col-lg-12"><p>Synthetic target section paragraph.</p>',
|
|
'<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Synthetic Target Entry</td></tr></tbody></table>',
|
|
'</div></div>',
|
|
'<!-- SubSection Next Section -->'
|
|
].join('');
|
|
const content = pingCastleHtml.replace('</main>', ruleWithBoth + targetSection + '</main>');
|
|
|
|
const dataset = await importPingCastle(content, 'ad_hc_both.synthetic.html', 'text/html');
|
|
|
|
const candidate = dataset.mapping?.candidates.find(
|
|
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-Both'
|
|
);
|
|
const details = candidate?.fields.find(field => field.label === 'Details')?.decodedValue;
|
|
expect(details).toContain('SYNTHETIC-DC');
|
|
expect(details).toContain('Synthetic Target Entry');
|
|
const linked = candidate?.fields.find(field => field.key === 'linked-section');
|
|
const sections = (linked?.originalValue as { sections?: Array<{ sectionId: string }> })?.sections ?? [];
|
|
expect(sections.map(section => section.sectionId)).toEqual(['rule-details-S-Both', 'syntheticsection']);
|
|
});
|
|
|
|
it('follows modal and collapse targets recursively and keeps every reached table separate', async () => {
|
|
const ruleWithGraph = [
|
|
'<article><h3>Synthetic recursive result graph</h3>',
|
|
'<p><strong>Rule ID:</strong> S-Graph</p>',
|
|
'<strong>Details:</strong><p>The detail can be found in <a href="#graphsection">Synthetic Graph</a></p>',
|
|
'<table><thead><tr><th>DN</th><th>Reason</th></tr></thead><tbody>',
|
|
'<tr><td>CN=Synthetic,DC=example,DC=test</td><td>Synthetic reason</td></tr></tbody></table>',
|
|
'<p><strong>Points:</strong> 10 points if present</p></article>'
|
|
].join('');
|
|
const targetGraph = [
|
|
'<!-- SubSection Synthetic Graph --><a name="graphsection"></a>',
|
|
'<h2 class="sub-section">Synthetic Graph</h2>',
|
|
'<!-- SubSection Synthetic Graph end -->',
|
|
'<button data-bs-toggle="collapse" data-bs-target="#collapsedEvidence" aria-controls="collapsedEvidence">All synthetic users</button>',
|
|
'<table><thead><tr><th>Group</th></tr></thead><tbody><tr><td>',
|
|
'<a data-bs-toggle="modal" href="#modalSynthetic">Synthetic Administrators</a>',
|
|
'</td></tr></tbody></table>',
|
|
'<div class="modal" id="modalSynthetic"><h4 class="modal-title">Synthetic Administrators</h4>',
|
|
'<table><thead><tr><th>Account</th></tr></thead><tbody><tr><td>',
|
|
'<a data-bs-toggle="modal" href="#modalNested">Synthetic Account</a>',
|
|
'</td></tr></tbody></table></div>',
|
|
'<div class="modal" id="modalNested"><h4 class="modal-title">Nested Evidence</h4>',
|
|
'<a href="#modalSynthetic">Cycle back</a>',
|
|
'<table><thead><tr><th>Property</th></tr></thead><tbody><tr><td>First</td></tr></tbody></table>',
|
|
'<table><thead><tr><th>Property</th></tr></thead><tbody><tr><td>Second</td></tr></tbody></table>',
|
|
'</div>',
|
|
'<div id="collapsedEvidence" class="collapse"><table><thead><tr><th>User</th></tr></thead>',
|
|
'<tbody><tr><td>Synthetic User</td></tr></tbody></table></div>',
|
|
'<a href="#missingTarget">Missing target</a>',
|
|
'<a href="javascript:alert(1)">Unsafe</a><a href="https://example.invalid/">External</a>',
|
|
'<!-- SubSection Unrelated --><a name="unrelated"></a>',
|
|
'<!-- SubSection Unrelated end --><table><tr><th>Must not leak</th></tr><tr><td>Unrelated</td></tr></table>'
|
|
].join('');
|
|
const content = pingCastleHtml.replace('</main>', ruleWithGraph + targetGraph + '</main>');
|
|
|
|
const dataset = await importPingCastle(content, 'ad_hc_graph.synthetic.html', 'text/html');
|
|
const candidate = dataset.mapping?.candidates.find(
|
|
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-Graph'
|
|
);
|
|
const linked = candidate?.fields.find(field => field.key === 'linked-section');
|
|
const sections = (linked?.originalValue as {
|
|
sections?: Array<{ sectionId: string; sectionLabel: string; headers: string[]; rows: string[][] }>
|
|
})?.sections ?? [];
|
|
|
|
expect(sections.map(section => section.sectionId)).toEqual([
|
|
'rule-details-S-Graph',
|
|
'graphsection',
|
|
'modalSynthetic',
|
|
'modalNested--table-1',
|
|
'modalNested--table-2',
|
|
'collapsedEvidence'
|
|
]);
|
|
expect(sections.map(section => section.sectionLabel)).toEqual([
|
|
'Details',
|
|
'Synthetic Graph',
|
|
'Synthetic Graph → Synthetic Administrators',
|
|
'Synthetic Graph → Synthetic Administrators → Nested Evidence · Tabelle 1',
|
|
'Synthetic Graph → Synthetic Administrators → Nested Evidence · Tabelle 2',
|
|
'Synthetic Graph → All synthetic users'
|
|
]);
|
|
expect(sections.flatMap(section => section.rows).flat()).not.toContain('Unrelated');
|
|
expect(sections.flatMap(section => section.headers)).not.toContain('Must not leak');
|
|
expect(dataset.intermediate?.diagnostics).toContainEqual(expect.objectContaining({
|
|
code: 'pingcastle-linked-section-unresolved',
|
|
details: { targetId: 'missingTarget', reason: 'unresolved' }
|
|
}));
|
|
});
|
|
|
|
it('prefers the PingCastle card-header rationale as finding title and keeps the check description', async () => {
|
|
const ruleWithHeader = [
|
|
'<div class="card"><div class="card-header" id="headingrulesS-CardTitle">',
|
|
'<span class="card-title mb-0"><button class="btn btn-link" data-bs-toggle="collapse" data-bs-target="#rulesS-CardTitle">Synthetic rationale headline</button></span>',
|
|
"<i class=\"float-end\"><span class='float-end'>+ 5 Point(s)</span></i></div>",
|
|
'<div id="rulesS-CardTitle" class="collapse"><div class="card-body risk-card"><article>',
|
|
'<h3>Synthetic check description</h3>',
|
|
'<p><strong>Rule ID:</strong> S-CardTitle</p>',
|
|
'<p><strong>Description:</strong> Synthetic finding with distinct header and check text.</p>',
|
|
'<p><strong>Points:</strong> 5 points if present</p>',
|
|
'</article></div></div></div>'
|
|
].join('');
|
|
const content = pingCastleHtml.replace('</main>', ruleWithHeader + '</main>');
|
|
|
|
const dataset = await importPingCastle(content, 'ad_hc_cardtitle.synthetic.html', 'text/html');
|
|
|
|
const candidate = dataset.mapping?.candidates.find(
|
|
item => item.fields.find(field => field.role === 'rule-id')?.decodedValue === 'S-CardTitle'
|
|
);
|
|
expect(candidate?.fields.find(field => field.role === 'title')?.decodedValue).toBe('Synthetic rationale headline');
|
|
expect(candidate?.fields.find(field => field.label === 'Check title')?.decodedValue).toBe('Synthetic check description');
|
|
});
|
|
});
|