Initial Commit
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user