test(16-01): add failing tests for parseEml/parseAuthResults/extractUrls/buildBodyPreview

RED: covers EVID-03 (normalized headers, structured auth verdicts,
Received chain, URLs, attachment metadata incl. related flag),
EVID-04 (no-network spy, truncated body preview), and the DoS size
guard (oversized buffer rejected before simpleParser). New synthetic
fixtures: rich multipart, auth-results-original, inline/CID attachment,
long-body, fuzzy-URL, and an oversized-buffer generator. None of these
exports exist on eml-parser.ts yet.
This commit is contained in:
lorentz 2026-07-15 10:28:22 -04:00
parent e4718ae71a
commit 4df4816b21
2 changed files with 320 additions and 2 deletions

View file

@ -69,3 +69,159 @@ export const NO_EML_ATTACHMENTS: Attachment[] = [
/** Empty attachment list. */
export const EMPTY_ATTACHMENTS: Attachment[] = [];
// ---------------------------------------------------------------------------
// Synthetic raw .eml buffers for parseEml (EVID-03/EVID-04). All addresses,
// bodies, and content below are invented for testing only — no real
// customer email content per this milestone's Out of Scope constraint.
// ---------------------------------------------------------------------------
/**
* Rich multipart fixture: text + html bodies (each carrying the same URL),
* one non-inline base64 attachment, and an Authentication-Results header
* with spf=pass, dkim=fail, dmarc=none.
*/
export const RICH_MULTIPART_EML = Buffer.from(
`From: "Attacker Corp" <attacker@evil-example.test>
To: victim@wulfconsulting.test
Cc: cc-user@wulfconsulting.test
Reply-To: reply@evil-example.test
Return-Path: <bounce@evil-example.test>
Subject: Urgent: verify your account
Date: Mon, 15 Jul 2026 12:00:00 +0000
Message-ID: <rich1@evil-example.test>
Authentication-Results: mx.wulfconsulting.test; spf=pass smtp.mailfrom=evil-example.test; dkim=fail header.d=evil-example.test; dmarc=none header.from=evil-example.test
Received: from mx1.example.test by mx2.example.test; Mon, 15 Jul 2026 11:59:00 +0000
Received: from mx0.example.test by mx1.example.test; Mon, 15 Jul 2026 11:58:00 +0000
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="BOUNDARY1"
--BOUNDARY1
Content-Type: multipart/alternative; boundary="BOUNDARY2"
--BOUNDARY2
Content-Type: text/plain; charset="UTF-8"
Please visit http://evil-example.test/verify to verify your account.
--BOUNDARY2
Content-Type: text/html; charset="UTF-8"
<html><body><p>Please visit <a href="http://evil-example.test/verify">this link</a> to verify your account.</p></body></html>
--BOUNDARY2--
--BOUNDARY1
Content-Type: application/pdf; name="invoice.pdf"
Content-Disposition: attachment; filename="invoice.pdf"
Content-Transfer-Encoding: base64
SGVsbG8gV29ybGQh
--BOUNDARY1--
`
);
/** Same as RICH_MULTIPART_EML but also carries Authentication-Results-Original. */
export const RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML = Buffer.from(
`From: "Attacker Corp" <attacker@evil-example.test>
To: victim@wulfconsulting.test
Subject: Urgent: verify your account (remediated)
Date: Mon, 15 Jul 2026 12:00:00 +0000
Message-ID: <rich2@evil-example.test>
Authentication-Results: mx.wulfconsulting.test; spf=fail smtp.mailfrom=evil-example.test; dkim=fail header.d=evil-example.test; dmarc=fail header.from=evil-example.test
Authentication-Results-Original: mx.wulfconsulting.test; spf=pass smtp.mailfrom=evil-example.test; dkim=pass header.d=evil-example.test; dmarc=pass header.from=evil-example.test
MIME-Version: 1.0
Content-Type: text/plain; charset="UTF-8"
Body text for the remediated-header fixture.
`
);
/**
* Inline/CID attachment fixture: html references cid:sig123, wrapped in
* multipart/related so mailparser marks the image attachment `related: true`
* (Pitfall 5 inline parts must be kept, not dropped).
*/
export const INLINE_ATTACHMENT_EML = Buffer.from(
`From: sender@evil-example.test
To: victim@wulfconsulting.test
Subject: Newsletter with inline logo
Date: Mon, 15 Jul 2026 12:00:00 +0000
Message-ID: <inline1@evil-example.test>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="RELBOUND"
--RELBOUND
Content-Type: multipart/alternative; boundary="ALTBOUND"
--ALTBOUND
Content-Type: text/plain; charset="UTF-8"
Plain text body with an inline logo.
--ALTBOUND
Content-Type: text/html; charset="UTF-8"
<html><body><p>Hello</p><img src="cid:sig123"></body></html>
--ALTBOUND--
--RELBOUND
Content-Type: image/png; name="sig.png"
Content-Disposition: inline
Content-ID: <sig123>
Content-Transfer-Encoding: base64
iVBORw0KGgo=
--RELBOUND--
`
);
/** Long-body fixture — long enough to force buildBodyPreview truncation. */
const LONG_PARAGRAPH =
'This is a long paragraph of synthetic phishing-style body text repeated ' +
'several times to exceed the body preview truncation threshold. '.repeat(20);
export const LONG_BODY_EML = Buffer.from(
`From: sender@evil-example.test
To: victim@wulfconsulting.test
Subject: Long body fixture
Date: Mon, 15 Jul 2026 12:00:00 +0000
Message-ID: <long1@evil-example.test>
MIME-Version: 1.0
Content-Type: text/plain; charset="UTF-8"
${LONG_PARAGRAPH}
`
);
/** Fuzzy (scheme-less) www. URL fixture, to exercise linkify-it's fuzzyLink mode. */
export const FUZZY_URL_EML = Buffer.from(
`From: sender@evil-example.test
To: victim@wulfconsulting.test
Subject: Fuzzy URL fixture
Date: Mon, 15 Jul 2026 12:00:00 +0000
Message-ID: <fuzzy1@evil-example.test>
MIME-Version: 1.0
Content-Type: text/plain; charset="UTF-8"
Visit www.evil-example.com/login to reset your password.
`
);
/** Oversized buffer — exceeds MAX_EML_BYTES, must be rejected before simpleParser runs. */
export function makeOversizedEmlBuffer(maxBytes: number): Buffer {
const header = `From: sender@evil-example.test
To: victim@wulfconsulting.test
Subject: Oversized fixture
Date: Mon, 15 Jul 2026 12:00:00 +0000
Message-ID: <oversized1@evil-example.test>
MIME-Version: 1.0
Content-Type: text/plain; charset="UTF-8"
`;
const padding = 'A'.repeat(maxBytes + 1024 - header.length);
return Buffer.from(header + padding);
}

View file

@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { selectOriginalMessage } from './eml-parser';
import { describe, it, expect, vi } from 'vitest';
import {
selectOriginalMessage,
parseEml,
parseAuthResults,
extractUrls,
buildBodyPreview,
MAX_EML_BYTES,
} from './eml-parser';
import {
RFC_EML_TIER_ATTACHMENTS,
RFC_EML_TIER_ATTACHMENTS_UPPERCASE,
@ -9,6 +16,12 @@ import {
AMBIGUOUS_NO_FALLBACK_ATTACHMENTS,
NO_EML_ATTACHMENTS,
EMPTY_ATTACHMENTS,
RICH_MULTIPART_EML,
RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML,
INLINE_ATTACHMENT_EML,
LONG_BODY_EML,
FUZZY_URL_EML,
makeOversizedEmlBuffer,
} from './eml-parser.fixtures';
describe('selectOriginalMessage', () => {
@ -55,3 +68,152 @@ describe('selectOriginalMessage', () => {
expect(selectOriginalMessage(EMPTY_ATTACHMENTS)).toBeNull();
});
});
describe('parseAuthResults', () => {
it('parses spf/dkim/dmarc verdicts from a raw Authentication-Results header value', () => {
const result = parseAuthResults(
'mx.wulfconsulting.test; spf=pass smtp.mailfrom=evil-example.test; dkim=fail header.d=evil-example.test; dmarc=none header.from=evil-example.test'
);
expect(result).toEqual({ spf: 'pass', dkim: 'fail', dmarc: 'none' });
});
it('is case-insensitive on method and result tokens', () => {
const result = parseAuthResults('mx.test; SPF=PASS; DKIM=Fail; DMARC=None');
expect(result).toEqual({ spf: 'pass', dkim: 'fail', dmarc: 'none' });
});
it('omits methods not present in the header', () => {
const result = parseAuthResults('mx.test; spf=softfail');
expect(result).toEqual({ spf: 'softfail' });
});
});
describe('extractUrls', () => {
it('extracts and dedupes URLs from both text and html parts', () => {
const urls = extractUrls(
'Visit http://evil-example.test/verify now.',
'<a href="http://evil-example.test/verify">link</a>'
);
expect(urls).toEqual(['http://evil-example.test/verify']);
});
it('extracts fuzzy (scheme-less) www. URLs', () => {
const urls = extractUrls('Visit www.evil-example.com/login to reset.', null);
expect(urls.some((u) => u.includes('evil-example.com/login'))).toBe(true);
});
it('returns an empty array when no URLs are present', () => {
expect(extractUrls('no links here', null)).toEqual([]);
});
it('handles null/undefined text and html gracefully', () => {
expect(extractUrls(null, undefined)).toEqual([]);
});
});
describe('buildBodyPreview', () => {
it('prefers plain text over html', () => {
const preview = buildBodyPreview('plain text body', '<p>html body</p>');
expect(preview).toContain('plain text body');
});
it('falls back to a stripped version of html when text is absent', () => {
const preview = buildBodyPreview(null, '<p>Hello <b>world</b></p>');
expect(preview).toContain('Hello');
expect(preview).toContain('world');
expect(preview).not.toContain('<p>');
expect(preview).not.toContain('<b>');
});
it('truncates a long body and stays distinct from the raw text', () => {
const longText = 'x'.repeat(2000);
const preview = buildBodyPreview(longText, null);
expect(preview.length).toBeLessThan(longText.length);
expect(preview).not.toBe(longText);
});
});
describe('parseEml', () => {
it('normalizes headers, auth results, received chain, urls, and attachment metadata from a synthetic fixture', async () => {
const result = await parseEml(RICH_MULTIPART_EML);
expect(result.from.email).toBe('attacker@evil-example.test');
expect(result.from.displayName).toBe('Attacker Corp');
expect(result.from.domain).toBe('evil-example.test');
expect(result.replyTo).toBe('reply@evil-example.test');
expect(result.returnPath).toBe('bounce@evil-example.test');
expect(result.to).toContain('victim@wulfconsulting.test');
expect(result.cc).toContain('cc-user@wulfconsulting.test');
expect(result.subject).toBe('Urgent: verify your account');
expect(result.date).toBeTruthy();
expect(result.messageId).toBe('<rich1@evil-example.test>');
expect(result.receivedChain).toHaveLength(2);
expect(result.authResults).toEqual({ spf: 'pass', dkim: 'fail', dmarc: 'none' });
expect(result.authResultsOriginal).toBeNull();
expect(result.urls).toContain('http://evil-example.test/verify');
expect(result.attachments).toHaveLength(1);
expect(result.attachments[0].filename).toBe('invoice.pdf');
expect(result.attachments[0].contentType).toBe('application/pdf');
expect(result.attachments[0].checksum).toMatch(/^[a-f0-9]{64}$/);
expect(result.attachments[0].related).toBe(false);
expect(result.bodyPreview).toBeTruthy();
});
it('populates authResultsOriginal when an Authentication-Results-Original header is present', async () => {
const result = await parseEml(RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML);
expect(result.authResults).toEqual({ spf: 'fail', dkim: 'fail', dmarc: 'fail' });
expect(result.authResultsOriginal).toEqual({ spf: 'pass', dkim: 'pass', dmarc: 'pass' });
});
it('preserves inline/related attachments rather than dropping them', async () => {
const result = await parseEml(INLINE_ATTACHMENT_EML);
expect(result.attachments).toHaveLength(1);
expect(result.attachments[0].related).toBe(true);
});
it('produces a body preview that is truncated and distinct from the raw body', async () => {
const result = await parseEml(LONG_BODY_EML);
expect(result.bodyPreview.length).toBeLessThan(2000);
});
it('extracts fuzzy www. URLs from a real parsed message', async () => {
const result = await parseEml(FUZZY_URL_EML);
expect(result.urls.some((u) => u.includes('evil-example.com/login'))).toBe(true);
});
it('never makes a network call while parsing any fixture', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
await parseEml(RICH_MULTIPART_EML);
await parseEml(RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML);
await parseEml(INLINE_ATTACHMENT_EML);
await parseEml(LONG_BODY_EML);
await parseEml(FUZZY_URL_EML);
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
it('rejects a buffer larger than MAX_EML_BYTES before simpleParser is called', async () => {
const oversized = makeOversizedEmlBuffer(MAX_EML_BYTES);
expect(oversized.byteLength).toBeGreaterThan(MAX_EML_BYTES);
await expect(parseEml(oversized)).rejects.toThrow();
});
it('does not contain any real customer email content in any fixture', () => {
const allFixtures = [
RICH_MULTIPART_EML,
RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML,
INLINE_ATTACHMENT_EML,
LONG_BODY_EML,
FUZZY_URL_EML,
];
for (const fixture of allFixtures) {
const text = fixture.toString('utf-8');
expect(text).not.toMatch(/wulfconsulting\.com/);
expect(text.includes('evil-example.test') || text.includes('evil-example.com')).toBe(true);
}
});
});