diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 9d3936a..dc6b52e 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "ac5d44785fa061e2694384d5ad5fe8f6a001ceb3226119404a00cba6cc7c8684", + "compatibility_surface_sha256": "ded2577b0f66b63d6ea65fe3d315d69dce68b0fa930f2b6d9106b7e0b52b526d", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index f6fb0d8..7cbd46f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "052b45f94751a01a1b7960cd861d1c122b49f0b80088b0434fc33e958396992d" + "sha256": "8d13e54b487bf27984ed988abee126d90e63a21a72d12aa21a819796bff4c569" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "ac5d44785fa061e2694384d5ad5fe8f6a001ceb3226119404a00cba6cc7c8684" + "manifest_sha256": "ded2577b0f66b63d6ea65fe3d315d69dce68b0fa930f2b6d9106b7e0b52b526d" } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5c2bf43..7f98840 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2843,8 +2843,7 @@ fn save_report_download_bytes( contents: &[u8], ) -> Result { use tauri::Manager as _; - let file_name = portable_export_file_name(file_name)?; - + let file_name = checked_export_file_name(file_name)?; // Tauri's own path resolver, so this needs no extra crate and no // capability grant. let downloads = app @@ -2857,6 +2856,10 @@ fn save_report_download_bytes( Ok(path.to_string_lossy().into_owned()) } +fn checked_export_file_name(file_name: &str) -> Result { + portable_export_file_name(file_name) +} + /// Reveals an exported file in the OS file manager. /// /// Only ever called with a path this process just wrote, and the path is @@ -3244,6 +3247,32 @@ mod party_statement_export_tests { let pdf: ExportPartyStatementRequest = serde_json::from_value(pdf).unwrap(); assert!(matches!(pdf.format, PartyStatementFormat::Pdf)); } + + #[test] + fn local_export_file_names_reject_path_like_and_hidden_values() { + assert_eq!( + checked_export_file_name(" statement.csv ").unwrap(), + "statement.csv" + ); + for name in [ + "", + ".hidden.csv", + "../statement.csv", + "nested/report.csv", + "nested\\report.csv", + ] { + assert!( + checked_export_file_name(name).is_err(), + "{name:?} must be rejected" + ); + } + } + + #[test] + fn statement_party_slug_is_portable_and_nonempty() { + assert_eq!(statement_filename_slug(" ../Aarav & Sons "), "aarav-sons"); + assert_eq!(statement_filename_slug("///"), "party"); + } } #[derive(Debug, Deserialize)] diff --git a/src-tauri/src/reports/bulk_party_statement.rs b/src-tauri/src/reports/bulk_party_statement.rs index 69399e1..4d7d4ac 100644 --- a/src-tauri/src/reports/bulk_party_statement.rs +++ b/src-tauri/src/reports/bulk_party_statement.rs @@ -301,6 +301,10 @@ mod tests { let file = destination.path().join(&result.written[0].file_name); assert!(file.starts_with(destination.path())); assert!(file.is_file()); + assert_eq!( + fs::read(&file).expect("statement bytes are readable"), + b"synthetic workbook" + ); assert_eq!( result.written[0].file_name, "statement-etc-passwd-20260808.xlsx" @@ -369,6 +373,29 @@ mod tests { assert_eq!(result.written[0].payable_amount, "7"); } + #[test] + fn per_party_file_creation_failure_is_retained_in_the_manifest() { + let destination = tempfile::tempdir().expect("temporary destination"); + let result = write_bulk_party_statements( + destination.path(), + "Synthetic Books Pvt Ltd", + "20260808", + "pdf/invalid", + &[bill("Write Failure", "10.00")], + &[], + |_| Ok(b"synthetic PDF".to_vec()), + ) + .expect("a partial batch result is returned"); + + assert!(result.written.is_empty()); + assert_eq!(result.failures.len(), 1); + assert_eq!(result.failures[0].party, "Write Failure"); + assert!(result.failures[0].error.contains("could not create")); + let manifest = fs::read_to_string(&result.manifest_path).expect("manifest is written"); + assert!(manifest.contains("Write Failure")); + assert!(manifest.contains("could not create")); + } + #[test] fn colliding_safe_names_are_written_to_distinct_files() { let destination = tempfile::tempdir().expect("temporary destination"); diff --git a/src-tauri/src/reports/party_statement_pdf.rs b/src-tauri/src/reports/party_statement_pdf.rs index 050be7e..db54d15 100644 --- a/src-tauri/src/reports/party_statement_pdf.rs +++ b/src-tauri/src/reports/party_statement_pdf.rs @@ -587,6 +587,23 @@ mod tests { )); } + #[test] + fn invalid_statement_date_fails_instead_of_using_a_default() { + let statement = build_party_statement( + "Synthetic Books Pvt Ltd", + "not-a-date", + "Synthetic Party", + &[bill("INV-1", "10.00", 5)], + &[], + ) + .expect("the renderer owns document-date validation"); + + assert!(matches!( + render_party_statement_pdf(&statement), + Err(PartyStatementPdfError::InvalidDate(value)) if value == "not-a-date" + )); + } + #[test] fn an_unrepresentable_amount_fails_instead_of_becoming_zero() { assert_eq!(