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:
parent
e4718ae71a
commit
4df4816b21
2 changed files with 320 additions and 2 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue