From b36122da2162971832362a7b636581bf67440fb3 Mon Sep 17 00:00:00 2001 From: Vsevolod Stakhov Date: Mon, 13 Apr 2026 14:00:28 +0100 Subject: [PATCH] [Test] lua_feedback_parsers: add unit tests for DSN and ARF Cover the pure helpers (strip_angles, parse_field_blocks) directly via internal exports and exercise parse_dsn / parse_arf end-to-end on synthetic tasks built with rspamd_task.load_from_string. Tests cover header folding, repeated fields, CRLF normalisation, original-message extraction, and detection of non-report messages. --- lualib/lua_feedback_parsers.lua | 4 + test/lua/unit/lua_feedback_parsers.lua | 263 +++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 test/lua/unit/lua_feedback_parsers.lua diff --git a/lualib/lua_feedback_parsers.lua b/lualib/lua_feedback_parsers.lua index e11f2a0ddf..6e24fc29a9 100644 --- a/lualib/lua_feedback_parsers.lua +++ b/lualib/lua_feedback_parsers.lua @@ -411,4 +411,8 @@ function exports.parse_arf(task) return result end +-- Exposed for unit tests. +exports._parse_field_blocks = parse_field_blocks +exports._strip_angles = strip_angles + return exports diff --git a/test/lua/unit/lua_feedback_parsers.lua b/test/lua/unit/lua_feedback_parsers.lua new file mode 100644 index 0000000000..88f441e17b --- /dev/null +++ b/test/lua/unit/lua_feedback_parsers.lua @@ -0,0 +1,263 @@ +-- Tests for lua_feedback_parsers module (DSN and ARF parsing) + +context("Lua feedback parsers - pure helpers", function() + local lua_feedback_parsers = require "lua_feedback_parsers" + + context("strip_angles", function() + test("simple angle-bracketed id", function() + assert_equal("abc@example.com", + lua_feedback_parsers._strip_angles("")) + end) + + test("with surrounding whitespace", function() + assert_equal("abc@example.com", + lua_feedback_parsers._strip_angles(" ")) + end) + + test("no angles - returns trimmed input", function() + assert_equal("abc@example.com", + lua_feedback_parsers._strip_angles(" abc@example.com ")) + end) + + test("nil input", function() + assert_nil(lua_feedback_parsers._strip_angles(nil)) + end) + end) + + context("parse_field_blocks", function() + test("single block, simple fields", function() + local body = "Reporting-MTA: dns; mta.example.com\r\n" .. + "Arrival-Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n" + local blocks = lua_feedback_parsers._parse_field_blocks(body) + assert_equal(1, #blocks) + assert_equal("dns; mta.example.com", blocks[1].fields["reporting-mta"]) + assert_equal("Mon, 01 Jan 2024 12:00:00 +0000", + blocks[1].fields["arrival-date"]) + end) + + test("multiple blocks separated by blank lines", function() + local body = "Reporting-MTA: dns; mta.example.com\n\n" .. + "Final-Recipient: rfc822; user@example.com\n" .. + "Action: failed\n" .. + "Status: 5.1.1\n" + local blocks = lua_feedback_parsers._parse_field_blocks(body) + assert_equal(2, #blocks) + assert_equal("dns; mta.example.com", blocks[1].fields["reporting-mta"]) + assert_equal("rfc822; user@example.com", + blocks[2].fields["final-recipient"]) + assert_equal("failed", blocks[2].fields["action"]) + assert_equal("5.1.1", blocks[2].fields["status"]) + end) + + test("header folding (continuation lines)", function() + local body = "Diagnostic-Code: smtp;\n" .. + " 550 5.1.1 user unknown\n" .. + "\tplease verify the address\n" + local blocks = lua_feedback_parsers._parse_field_blocks(body) + assert_equal(1, #blocks) + assert_equal("smtp; 550 5.1.1 user unknown please verify the address", + blocks[1].fields["diagnostic-code"]) + end) + + test("repeated fields collected in fields_multi", function() + local body = "Reported-Uri: http://a.example/\n" .. + "Reported-Uri: http://b.example/\n" .. + "Reported-Uri: http://c.example/\n" + local blocks = lua_feedback_parsers._parse_field_blocks(body) + assert_equal(1, #blocks) + local uris = blocks[1].fields_multi["reported-uri"] + assert_not_nil(uris) + assert_equal(3, #uris) + assert_equal("http://a.example/", uris[1]) + assert_equal("http://b.example/", uris[2]) + assert_equal("http://c.example/", uris[3]) + end) + + test("empty body returns empty list", function() + assert_equal(0, #lua_feedback_parsers._parse_field_blocks("")) + end) + + test("CRLF normalisation", function() + local body = "Feedback-Type: abuse\r\nVersion: 1\r\n" + local blocks = lua_feedback_parsers._parse_field_blocks(body) + assert_equal(1, #blocks) + assert_equal("abuse", blocks[1].fields["feedback-type"]) + assert_equal("1", blocks[1].fields["version"]) + end) + end) +end) + +context("Lua feedback parsers - DSN/ARF on synthetic tasks", function() + local rspamd_task = require "rspamd_task" + local rspamd_util = require "rspamd_util" + local rspamd_test_helper = require "rspamd_test_helper" + local lua_feedback_parsers = require "lua_feedback_parsers" + + rspamd_test_helper.init_url_parser() + local cfg = rspamd_util.config_from_ucl(rspamd_test_helper.default_config(), + "INIT_URL,INIT_LIBS,INIT_SYMCACHE,INIT_VALIDATE,INIT_PRELOAD_MAPS") + + local function load_task(message) + local res, task = rspamd_task.load_from_string(message, cfg) + if not res or not task then + return nil + end + task:process_message() + return task + end + + test("parse_dsn on RFC 3464 multipart/report", function() + local message = "Return-Path: <>\r\n" .. + "From: MAILER-DAEMON@mta.example.com\r\n" .. + "To: sender@example.org\r\n" .. + "Subject: Undelivered Mail Returned to Sender\r\n" .. + "Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n" .. + "Message-ID: \r\n" .. + "MIME-Version: 1.0\r\n" .. + "Content-Type: multipart/report; report-type=delivery-status; boundary=\"bnd0\"\r\n" .. + "\r\n" .. + "--bnd0\r\n" .. + "Content-Type: text/plain; charset=us-ascii\r\n" .. + "\r\n" .. + "This is the mail system at mta.example.com.\r\n" .. + "\r\n" .. + "I'm sorry to have to inform you that your message could not be delivered.\r\n" .. + "\r\n" .. + "--bnd0\r\n" .. + "Content-Type: message/delivery-status\r\n" .. + "\r\n" .. + "Reporting-MTA: dns; mta.example.com\r\n" .. + "Arrival-Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n" .. + "\r\n" .. + "Final-Recipient: rfc822; user@bad.example.com\r\n" .. + "Action: failed\r\n" .. + "Status: 5.1.1\r\n" .. + "Diagnostic-Code: smtp; 550 5.1.1 user unknown\r\n" .. + "Remote-MTA: dns; mx.bad.example.com\r\n" .. + "\r\n" .. + "--bnd0\r\n" .. + "Content-Type: message/rfc822\r\n" .. + "\r\n" .. + "From: sender@example.org\r\n" .. + "To: user@bad.example.com\r\n" .. + "Subject: Hi\r\n" .. + "Date: Mon, 01 Jan 2024 11:59:00 +0000\r\n" .. + "Message-ID: \r\n" .. + "\r\n" .. + "Original message body.\r\n" .. + "--bnd0--\r\n" + + local task = load_task(message) + assert_not_nil(task, "failed to load DSN message") + local dsn = lua_feedback_parsers.parse_dsn(task) + assert_not_nil(dsn) + assert_equal("dns; mta.example.com", dsn.reporting_mta) + assert_equal("Mon, 01 Jan 2024 12:00:00 +0000", dsn.arrival_date) + assert_equal(1, #dsn.recipients) + assert_equal("rfc822; user@bad.example.com", + dsn.recipients[1].final_recipient) + assert_equal("failed", dsn.recipients[1].action) + assert_equal("5.1.1", dsn.recipients[1].status) + assert_equal("smtp; 550 5.1.1 user unknown", + dsn.recipients[1].diagnostic_code) + assert_equal("dns; mx.bad.example.com", dsn.recipients[1].remote_mta) + assert_not_nil(dsn.original_message) + assert_equal("orig-msg-001@example.org", dsn.original_message.message_id) + assert_equal("sender@example.org", dsn.original_message.from) + assert_equal("user@bad.example.com", dsn.original_message.to) + assert_equal("Hi", dsn.original_message.subject) + task:destroy() + end) + + test("parse_dsn returns nil on non-DSN message", function() + local message = "From: a@example.com\r\n" .. + "To: b@example.com\r\n" .. + "Subject: hello\r\n" .. + "\r\n" .. + "just a regular message\r\n" + local task = load_task(message) + assert_not_nil(task, "failed to load message") + assert_nil(lua_feedback_parsers.parse_dsn(task)) + task:destroy() + end) + + test("parse_arf on RFC 5965 feedback report", function() + local message = "Return-Path: \r\n" .. + "From: complaints@isp.example\r\n" .. + "To: fbl@example.org\r\n" .. + "Subject: FW: spam complaint\r\n" .. + "Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n" .. + "Message-ID: \r\n" .. + "MIME-Version: 1.0\r\n" .. + "Content-Type: multipart/report; report-type=feedback-report; boundary=\"bnd1\"\r\n" .. + "\r\n" .. + "--bnd1\r\n" .. + "Content-Type: text/plain; charset=us-ascii\r\n" .. + "\r\n" .. + "This is an email abuse report for an email message received from\r\n" .. + "IP 1.2.3.4 on Mon, 01 Jan 2024 11:55:00 +0000.\r\n" .. + "\r\n" .. + "--bnd1\r\n" .. + "Content-Type: message/feedback-report\r\n" .. + "\r\n" .. + "Feedback-Type: abuse\r\n" .. + "User-Agent: ISP-FBL/1.0\r\n" .. + "Version: 1\r\n" .. + "Original-Mail-From: \r\n" .. + "Original-Rcpt-To: \r\n" .. + "Arrival-Date: Mon, 01 Jan 2024 11:55:00 +0000\r\n" .. + "Source-IP: 1.2.3.4\r\n" .. + "Reported-Domain: example.org\r\n" .. + "Reported-Uri: http://example.org/landing\r\n" .. + "Reported-Uri: http://example.org/other\r\n" .. + "\r\n" .. + "--bnd1\r\n" .. + "Content-Type: message/rfc822\r\n" .. + "\r\n" .. + "From: sender@example.org\r\n" .. + "To: user@isp.example\r\n" .. + "Subject: Newsletter\r\n" .. + "Date: Mon, 01 Jan 2024 11:54:00 +0000\r\n" .. + "Message-ID: \r\n" .. + "\r\n" .. + "body\r\n" .. + "--bnd1--\r\n" + + local task = load_task(message) + assert_not_nil(task, "failed to load ARF message") + local arf = lua_feedback_parsers.parse_arf(task) + assert_not_nil(arf) + assert_equal("abuse", arf.feedback_type) + assert_equal("1", arf.version) + assert_equal("ISP-FBL/1.0", arf.user_agent) + assert_equal("sender@example.org", arf.original_mail_from) + assert_equal("user@isp.example", arf.original_rcpt_to) + assert_equal("1.2.3.4", arf.source_ip) + assert_equal("example.org", arf.reported_domain) + assert_equal(2, #arf.reported_uri) + assert_equal("http://example.org/landing", arf.reported_uri[1]) + assert_equal("http://example.org/other", arf.reported_uri[2]) + assert_not_nil(arf.original_message) + assert_equal("orig-fbl-001@example.org", arf.original_message.message_id) + assert_equal("sender@example.org", arf.original_message.from) + task:destroy() + end) + + test("parse_arf returns nil when report-type is not feedback-report", function() + local message = "From: a@example.com\r\n" .. + "To: b@example.com\r\n" .. + "Subject: not a fbl\r\n" .. + "MIME-Version: 1.0\r\n" .. + "Content-Type: multipart/report; report-type=delivery-status; boundary=\"x\"\r\n" .. + "\r\n" .. + "--x\r\n" .. + "Content-Type: text/plain\r\n" .. + "\r\n" .. + "stub\r\n" .. + "--x--\r\n" + local task = load_task(message) + assert_not_nil(task, "failed to load message") + assert_nil(lua_feedback_parsers.parse_arf(task)) + task:destroy() + end) +end) -- 2.47.3