// @vitest-environment jsdom import { describe, expect, it, vi } from 'vitest'; import { htmlAttributes, htmlAttributeValue } from '../../src/importers/html-attributes'; import { indexHtmlElements } from '../../src/importers/html-element-index'; import { decodeHtmlLexically, sanitizeHtmlText } from '../../src/importers/html-extraction'; import { protectHtmlAttributeMarkup } from '../../src/importers/html-attribute-markup'; import { importSource } from '../../src/importers/engine'; import { importProfiles } from '../../src/profiles/profile-catalog'; import { nodeImportRuntimeAdapters } from '../../server/node-import-runtime-adapters'; import { browserImportRuntimeAdapters } from '../../src/platform/browser/import-runtime-adapters'; import { htmlTimeoutFixture } from './html-timeout-fixture.mjs'; describe('bounded HTML attribute and target work', () => { it('retains quoted, unquoted, empty and boolean attributes without reading values as attributes', () => { const text = ` disabled ID='target' data-tooltip="value" zero=0 empty="" flag = false `; expect([...htmlAttributes(text)].map(({ name, value }) => [name, value])).toEqual([ ['disabled', undefined], ['id', 'target'], ['data-tooltip', "value"], ['zero', '0'], ['empty', ''], ['flag', 'false'] ]); expect(htmlAttributeValue(text, 'Id')).toBe('target'); expect(htmlAttributeValue(` id='first' id=second`, 'id')).toBe('first'); expect(htmlAttributeValue(` title="href='fake'"`, 'href')).toBe(''); for (const attribute of htmlAttributes(text)) expect(text.slice(attribute.start, attribute.end).toLowerCase()).toMatch(new RegExp('^' + attribute.name)); }); it.each([1024, 4096, 16_384])('performs bounded character checks for %i-character valueless attributes', size => { const text = ' ' + 'x'.repeat(size) + ' '.repeat(size) + ' id=retained'; const original = RegExp.prototype.exec; let largestRegexInput = 0; let checkedCharacters = 0; const spy = vi.spyOn(RegExp.prototype, 'exec').mockImplementation(function (this: RegExp, value: string) { largestRegexInput = Math.max(largestRegexInput, value.length); checkedCharacters += value.length; return original.call(this, value); }); let result: string; try { result = htmlAttributeValue(text, 'id'); } finally { spy.mockRestore(); } expect(result!).toBe('retained'); // No input-wide backtracking regex is allowed in the attribute scanner. expect(largestRegexInput).toBe(1); expect(checkedCharacters).toBeLessThan(text.length * 3); }); it('continues after malformed separators and preserves literal slash and entity values', () => { expect([...htmlAttributes(` / = > flag data-v=a/b&c empty='' tail="unterminated`)].map(({ name, value }) => [name, value])).toEqual([ ['flag', undefined], ['data-v', 'a/b&c'], ['empty', ''], ['tail', 'unterminated'] ]); }); it('sanitizes whole dangerous attributes while retaining harmless originals and safe links', () => { const source = `Text`; const sanitized = sanitizeHtmlText(source); expect(sanitized.text).toContain(`disabled data-tip="src='inert' <b>"`); expect(sanitized.text).toContain('href="https://example.invalid/?a=1&b=2"'); expect(sanitized.text).not.toMatch(/onclick|onfocus|src=bad/); expect(sanitized.removedContentCount).toBe(3); expect(sanitizeHtmlText('Text').text).not.toContain('href'); }); it('retains subsequent cells, table IDs and spans after long valueless attributes', () => { const source = `
| Columns | |
|---|---|
| 0 | false |
| tail | |
changed
').byId.get('first')?.tagName).toBe('p'); }); it('keeps every rule and linked detail in both runtimes for the multi-megabyte timeout reproducer', async () => { const text = htmlTimeoutFixture(); const bytes = new TextEncoder().encode(text); const source = { metadata: { name: 'ad_hc_synthetic.local.html', sizeBytes: bytes.length, lastModified: 0 }, bytes }; const server = await importSource(source, { profiles: importProfiles }, nodeImportRuntimeAdapters); const browser = await importSource(source, { profiles: importProfiles }, browserImportRuntimeAdapters); expect(browser.extraction).toEqual(server.extraction); expect(server.profileId).toBe('pingcastle'); expect(server.rawText).toBe(text); expect(server.extraction?.candidates).toHaveLength(40); expect(server.extraction?.diagnostics).toEqual([]); expect(server.intermediate?.tables).toHaveLength(40); server.extraction?.candidates.forEach((candidate, index) => { expect(candidate.fields.find(field => field.key === 'risk-id')?.originalValue).toBe(`S-${index}`); expect(candidate.fields.find(field => field.key === 'linked-section')?.originalValue).toMatchObject({ sections: [{ sectionId: `detail-${index}`, headers: ['Host'], rows: [[`host-${index}`]] }] }); }); }); });