Files
suaveplan/packages/communication/emails/dmarc/src/parsers/aggregate.test.ts
2026-05-27 23:29:53 +01:00

486 lines
17 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import { gzipSync, strToU8 } from "fflate";
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
import { parseDmarcAggregate } from "./aggregate.js";
const MINIMAL_XML = `<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<version>1.0</version>
<report_metadata>
<org_name>Example Corp</org_name>
<email>dmarc@example.com</email>
<report_id>12345</report_id>
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>none</p>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>192.0.2.1</source_ip>
<count>5</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>example.com</header_from>
</identifiers>
<auth_results>
<dkim><domain>example.com</domain><result>pass</result></dkim>
<spf><domain>example.com</domain><result>pass</result><scope>mfrom</scope></spf>
</auth_results>
</record>
</feedback>`;
const MULTI_RECORD_XML = `<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<version>1.0</version>
<report_metadata>
<org_name>Multi Corp</org_name>
<email>dmarc@multi.com</email>
<report_id>99</report_id>
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
</report_metadata>
<policy_published>
<domain>multi.com</domain>
<adkim>s</adkim>
<aspf>s</aspf>
<p>reject</p>
<sp>quarantine</sp>
<pct>50</pct>
</policy_published>
<record>
<row>
<source_ip>10.0.0.1</source_ip>
<count>3</count>
<policy_evaluated>
<disposition>reject</disposition>
<dkim>fail</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>multi.com</header_from>
<envelope_from>bounce@multi.com</envelope_from>
</identifiers>
<auth_results>
<dkim><domain>multi.com</domain><result>fail</result><selector>dkim1</selector></dkim>
<spf><domain>multi.com</domain><result>fail</result></spf>
</auth_results>
</record>
<record>
<row>
<source_ip>10.0.0.2</source_ip>
<count>1</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>multi.com</header_from>
</identifiers>
<auth_results>
<dkim><domain>multi.com</domain><result>pass</result></dkim>
<spf><domain>multi.com</domain><result>pass</result></spf>
</auth_results>
</record>
</feedback>`;
const ERROR_XML = `<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<version>1.0</version>
<report_metadata>
<org_name>Example Corp</org_name>
<email>dmarc@example.com</email>
<report_id>12345</report_id>
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
<error>Something went wrong</error>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>none</p>
<pct>100</pct>
</policy_published>
</feedback>`;
describe("parseDmarcAggregate", () => {
it("parses minimal valid XML", { timeout: 5000 }, () => {
const report = parseDmarcAggregate(MINIMAL_XML);
// fast-xml-parser coerces "1.0" to the number 1, which str() converts to "1"
expect(report.version).toBe("1");
expect(report.reportMetadata.orgName).toBe("Example Corp");
expect(report.reportMetadata.email).toBe("dmarc@example.com");
expect(report.reportMetadata.reportId).toBe("12345");
expect(report.reportMetadata.dateRange.begin).toBe(1609459200);
expect(report.reportMetadata.dateRange.end).toBe(1609545600);
});
it("maps policy_published fields correctly", { timeout: 5000 }, () => {
const report = parseDmarcAggregate(MINIMAL_XML);
expect(report.policyPublished.domain).toBe("example.com");
expect(report.policyPublished.adkim).toBe("r");
expect(report.policyPublished.aspf).toBe("r");
expect(report.policyPublished.p).toBe("none");
expect(report.policyPublished.pct).toBe(100);
});
it("maps record fields correctly", { timeout: 5000 }, () => {
const report = parseDmarcAggregate(MINIMAL_XML);
expect(report.record).toHaveLength(1);
const rec = report.record[0];
expect(rec.row.sourceIp).toBe("192.0.2.1");
expect(rec.row.count).toBe(5);
expect(rec.row.policyEvaluated.disposition).toBe("none");
expect(rec.row.policyEvaluated.dkim).toBe("pass");
expect(rec.row.policyEvaluated.spf).toBe("pass");
expect(rec.identifiers.headerFrom).toBe("example.com");
});
it("maps auth_results fields", { timeout: 5000 }, () => {
const report = parseDmarcAggregate(MINIMAL_XML);
const auth = report.record[0].authResults;
expect(auth.dkim).toBeDefined();
expect(auth.spf).toBeDefined();
});
it("parses gzip-compressed XML (Uint8Array)", { timeout: 5000 }, () => {
const compressed = gzipSync(strToU8(MINIMAL_XML));
const report = parseDmarcAggregate(compressed);
expect(report.reportMetadata.orgName).toBe("Example Corp");
});
it("parses plain XML as Uint8Array", { timeout: 5000 }, () => {
const bytes = new TextEncoder().encode(MINIMAL_XML);
const report = parseDmarcAggregate(bytes);
expect(report.reportMetadata.orgName).toBe("Example Corp");
});
it("handles multiple records", { timeout: 5000 }, () => {
const report = parseDmarcAggregate(MULTI_RECORD_XML);
expect(report.record).toHaveLength(2);
expect(report.policyPublished.sp).toBe("quarantine");
});
it("handles optional envelope_from", { timeout: 5000 }, () => {
const report = parseDmarcAggregate(MULTI_RECORD_XML);
expect(report.record[0].identifiers.envelopeFrom).toBe(
"bounce@multi.com"
);
});
it("handles dkim selector", { timeout: 5000 }, () => {
const report = parseDmarcAggregate(MULTI_RECORD_XML);
const dkim = report.record[0].authResults.dkim;
expect(dkim).toBeDefined();
});
it("parses report with error elements", { timeout: 5000 }, () => {
const report = parseDmarcAggregate(ERROR_XML);
expect(report.reportMetadata.errors).toEqual(["Something went wrong"]);
expect(report.record).toHaveLength(0);
});
it("throws DmarcError on missing <feedback>", { timeout: 5000 }, () => {
expect(() => parseDmarcAggregate("<notfeedback/>")).toThrowError(
DmarcError
);
try {
parseDmarcAggregate("<notfeedback/>");
} catch (err) {
expect(err).toBeInstanceOf(DmarcError);
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
}
});
it("throws DmarcError on missing <report_metadata>", {
timeout: 5000,
}, () => {
const xml = "<feedback><version>1.0</version></feedback>";
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
try {
parseDmarcAggregate(xml);
} catch (err) {
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
}
});
it("throws DmarcError on missing <policy_published>", {
timeout: 5000,
}, () => {
const xml = `<feedback>
<version>1.0</version>
<report_metadata>
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
<date_range><begin>0</begin><end>1</end></date_range>
</report_metadata>
</feedback>`;
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
try {
parseDmarcAggregate(xml);
} catch (err) {
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
}
});
it("throws DmarcError on malformed XML (unclosed attribute)", {
timeout: 5000,
}, () => {
expect(() =>
parseDmarcAggregate('<feedback attr="unclosed')
).toThrowError(DmarcError);
try {
parseDmarcAggregate('<feedback attr="unclosed');
} catch (err) {
expect(err).toBeInstanceOf(DmarcError);
}
});
it("throws DmarcError when record is missing <row>", {
timeout: 5000,
}, () => {
const xml = `<feedback>
<version>1.0</version>
<report_metadata>
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
<date_range><begin>0</begin><end>1</end></date_range>
</report_metadata>
<policy_published>
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
</policy_published>
<record><identifiers><header_from>x.com</header_from></identifiers></record>
</feedback>`;
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
try {
parseDmarcAggregate(xml);
} catch (err) {
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
}
});
it("throws DmarcError when record <row> is missing <policy_evaluated>", {
timeout: 5000,
}, () => {
const xml = `<feedback>
<version>1.0</version>
<report_metadata>
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
<date_range><begin>0</begin><end>1</end></date_range>
</report_metadata>
<policy_published>
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
</policy_published>
<record>
<row><source_ip>1.2.3.4</source_ip><count>1</count></row>
<identifiers><header_from>x.com</header_from></identifiers>
<auth_results/>
</record>
</feedback>`;
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
try {
parseDmarcAggregate(xml);
} catch (err) {
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
}
});
it("accepts decompress: true option explicitly", { timeout: 5000 }, () => {
const compressed = gzipSync(strToU8(MINIMAL_XML));
const report = parseDmarcAggregate(compressed, { decompress: true });
expect(report.reportMetadata.orgName).toBe("Example Corp");
});
it("handles multiple DKIM entries in auth_results", {
timeout: 5000,
}, () => {
const xml = `<feedback>
<version>1.0</version>
<report_metadata>
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
<date_range><begin>0</begin><end>1</end></date_range>
</report_metadata>
<policy_published>
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>1.2.3.4</source_ip><count>1</count>
<policy_evaluated>
<disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
</policy_evaluated>
</row>
<identifiers><header_from>x.com</header_from></identifiers>
<auth_results>
<dkim><domain>x.com</domain><result>pass</result><selector>sel1</selector></dkim>
<dkim><domain>y.com</domain><result>pass</result></dkim>
</auth_results>
</record>
</feedback>`;
const report = parseDmarcAggregate(xml);
expect(Array.isArray(report.record[0].authResults.dkim)).toBe(true);
});
it("handles invalid disposition value (schema validation failure)", {
timeout: 5000,
}, () => {
const xml = `<feedback>
<version>1.0</version>
<report_metadata>
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
<date_range><begin>0</begin><end>1</end></date_range>
</report_metadata>
<policy_published>
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>invalid_disposition</p><pct>100</pct>
</policy_published>
</feedback>`;
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
try {
parseDmarcAggregate(xml);
} catch (err) {
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
}
});
it("throws DmarcError when record element is an empty string (non-object)", {
timeout: 5000,
}, () => {
// <record/> produces "" string in fast-xml-parser, not an object
const xml = `<feedback>
<version>1.0</version>
<report_metadata>
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
<date_range><begin>0</begin><end>1</end></date_range>
</report_metadata>
<policy_published>
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
</policy_published>
<record/>
</feedback>`;
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
try {
parseDmarcAggregate(xml);
} catch (err) {
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
}
});
it("num() handles non-numeric string (NaN -> 0) and empty string (0) date range", {
timeout: 5000,
}, () => {
// NaN path: non-parseable string -> NaN -> 0
const xmlNaN = `<feedback>
<version>1.0</version>
<report_metadata>
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
<date_range><begin>notanumber</begin><end>alsonotanumber</end></date_range>
</report_metadata>
<policy_published>
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
</policy_published>
</feedback>`;
const reportNaN = parseDmarcAggregate(xmlNaN);
expect(reportNaN.reportMetadata.dateRange.begin).toBe(0);
// Finite path: empty string -> Number("") = 0 (finite) -> returns 0
const xmlEmpty = `<feedback>
<version>1.0</version>
<report_metadata>
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
<date_range><begin></begin><end></end></date_range>
</report_metadata>
<policy_published>
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
</policy_published>
</feedback>`;
const reportEmpty = parseDmarcAggregate(xmlEmpty);
expect(reportEmpty.reportMetadata.dateRange.begin).toBe(0);
});
it("adkim/aspf defaults to 'r' when absent from policy_published", {
timeout: 5000,
}, () => {
// Missing adkim/aspf elements triggers the || "r" fallback
const xml = `<feedback>
<version>1.0</version>
<report_metadata>
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
<date_range><begin>0</begin><end>1</end></date_range>
</report_metadata>
<policy_published>
<domain>x.com</domain><p>none</p><pct>100</pct>
</policy_published>
</feedback>`;
const report = parseDmarcAggregate(xml);
expect(report.policyPublished.adkim).toBe("r");
expect(report.policyPublished.aspf).toBe("r");
});
it("handles multiple SPF entries with scope in auth_results", {
timeout: 5000,
}, () => {
const xml = `<feedback>
<version>1.0</version>
<report_metadata>
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
<date_range><begin>0</begin><end>1</end></date_range>
</report_metadata>
<policy_published>
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>1.2.3.4</source_ip><count>1</count>
<policy_evaluated>
<disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
</policy_evaluated>
</row>
<identifiers><header_from>x.com</header_from></identifiers>
<auth_results>
<spf><domain>x.com</domain><result>pass</result><scope>mfrom</scope></spf>
<spf><domain>y.com</domain><result>fail</result></spf>
</auth_results>
</record>
</feedback>`;
const report = parseDmarcAggregate(xml);
const spf = report.record[0].authResults.spf;
expect(Array.isArray(spf)).toBe(true);
});
it("handles envelope_to in identifiers", { timeout: 5000 }, () => {
const xml = `<feedback>
<version>1.0</version>
<report_metadata>
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
<date_range><begin>0</begin><end>1</end></date_range>
</report_metadata>
<policy_published>
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>1.2.3.4</source_ip><count>1</count>
<policy_evaluated>
<disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>x.com</header_from>
<envelope_to>dest@x.com</envelope_to>
</identifiers>
<auth_results/>
</record>
</feedback>`;
const report = parseDmarcAggregate(xml);
expect(report.record[0].identifiers.envelopeTo).toBe("dest@x.com");
});
});