Skip to main content

max / makenotwork

audit Run 14 Phase 3: adversarial CSV import tests Fill the coverage gaps the audit flagged on the parser: - row-level parse-error recovery (unterminated quote row skipped, neighbours kept) - CRLF (Windows) line endings - email domain-dot rules (no-dot / leading-dot / trailing-dot / no-local rejected; plus-addressed accepted) - negative whole-number cents; amount overflow beyond i64 -> None (no panic) - duplicate emails all retained (dedup is downstream) - MAX_IMPORT_ROWS (100k) cap enforced Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 22:51 UTC
Commit: 9038c15fd8de61528502186b2a835e329e73312e
Parent: 2d06790
1 file changed, +74 insertions, -0 deletions
@@ -720,4 +720,78 @@ mod tests {
720 720 let payload = parse_csv(csv, &mapping).unwrap();
721 721 assert_eq!(payload.subscribers.len(), 2);
722 722 }
723 +
724 + // ── Adversarial: row-error recovery, malformed input, boundaries ──
725 +
726 + #[test]
727 + fn parse_csv_malformed_row_skipped_good_rows_survive() {
728 + // Second data row has an unterminated quote → a row-level parse error.
729 + // parse_csv must record the error, skip that row, and still keep the
730 + // surrounding valid rows rather than aborting the whole import.
731 + let csv = b"email\nalice@test.com\nbob@\"broken\ncarol@test.com\n";
732 + let mapping = ColumnMapping { email: Some(0), ..Default::default() };
733 + let payload = parse_csv(csv, &mapping).unwrap();
734 + let emails: Vec<&str> = payload.subscribers.iter().map(|s| s.email.as_str()).collect();
735 + assert!(emails.contains(&"alice@test.com"), "good row before the bad one kept");
736 + assert!(emails.contains(&"carol@test.com"), "good row after the bad one kept");
737 + }
738 +
739 + #[test]
740 + fn parse_csv_crlf_line_endings() {
741 + // Windows-exported CSVs use \r\n; trim(Trim::All) + the csv reader must
742 + // handle them without leaking a trailing \r into the values.
743 + let csv = b"email,name\r\nalice@test.com,Alice\r\nbob@test.com,Bob\r\n";
744 + let mapping = ColumnMapping { email: Some(0), name: Some(1), ..Default::default() };
745 + let payload = parse_csv(csv, &mapping).unwrap();
746 + assert_eq!(payload.subscribers.len(), 2);
747 + assert_eq!(payload.subscribers[0].name.as_deref(), Some("Alice"));
748 + assert_eq!(payload.subscribers[1].email, "bob@test.com");
749 + }
750 +
751 + #[test]
752 + fn email_domain_dot_rules_enforced() {
753 + // Rows that must be rejected by the inline email filter, plus one valid
754 + // plus-addressed email that must survive.
755 + let csv = b"email\nno-dot@localhost\nleading@.com\ntrailing@dot.\n@nolocal.com\nvalid+tag@sub.example.com\n";
756 + let mapping = ColumnMapping { email: Some(0), ..Default::default() };
757 + let payload = parse_csv(csv, &mapping).unwrap();
758 + assert_eq!(payload.subscribers.len(), 1, "only the well-formed email survives");
759 + assert_eq!(payload.subscribers[0].email, "valid+tag@sub.example.com");
760 + }
761 +
762 + #[test]
763 + fn parse_amount_negative_whole_number_is_negative_cents() {
764 + assert_eq!(parse_amount_cents("-500"), Some(-500));
765 + assert_eq!(parse_amount_cents("-$1,000"), Some(-1000));
766 + }
767 +
768 + #[test]
769 + fn parse_amount_overflow_returns_none() {
770 + // Beyond i64 range → parse fails → None, never a panic or wrapped value.
771 + assert_eq!(parse_amount_cents("99999999999999999999999"), None);
772 + assert_eq!(parse_amount_cents("999999999999999999999.99"), None);
773 + }
774 +
775 + #[test]
776 + fn parse_csv_duplicate_emails_all_retained() {
777 + // Deduplication is a downstream concern; the parser must faithfully emit
778 + // every valid row so the importer can decide how to merge.
779 + let csv = b"email\ndup@test.com\ndup@test.com\n";
780 + let mapping = ColumnMapping { email: Some(0), ..Default::default() };
781 + let payload = parse_csv(csv, &mapping).unwrap();
782 + assert_eq!(payload.subscribers.len(), 2);
783 + }
784 +
785 + #[test]
786 + fn parse_csv_enforces_row_cap() {
787 + // MAX_IMPORT_ROWS is 100_000; a CSV with more data rows must cap at the
788 + // limit rather than ingesting an unbounded upload.
789 + let mut csv = String::from("email\n");
790 + for i in 0..100_050 {
791 + csv.push_str(&format!("user{i}@test.com\n"));
792 + }
793 + let mapping = ColumnMapping { email: Some(0), ..Default::default() };
794 + let payload = parse_csv(csv.as_bytes(), &mapping).unwrap();
795 + assert_eq!(payload.subscribers.len(), 100_000, "capped at MAX_IMPORT_ROWS");
796 + }
723 797 }