From 82f8ddc38f279e1b7bea2c1103b784f5fdc1fb8e Mon Sep 17 00:00:00 2001
From: Rob Ede <robjtede@icloud.com>
Date: Wed, 14 Feb 2024 22:22:07 +0000
Subject: [PATCH] feat: multipart testing utilities (#3288)

---
 actix-multipart/CHANGES.md       |   1 +
 actix-multipart/Cargo.toml       |   3 +
 actix-multipart/src/form/json.rs |  51 +++++---
 actix-multipart/src/lib.rs       |   7 +-
 actix-multipart/src/server.rs    |  32 ++++-
 actix-multipart/src/test.rs      | 217 +++++++++++++++++++++++++++++++
 6 files changed, 287 insertions(+), 24 deletions(-)
 create mode 100644 actix-multipart/src/test.rs

diff --git a/actix-multipart/CHANGES.md b/actix-multipart/CHANGES.md
index adf37044..196d2ca9 100644
--- a/actix-multipart/CHANGES.md
+++ b/actix-multipart/CHANGES.md
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+- Add testing utilities under new module `test`.
 - Minimum supported Rust version (MSRV) is now 1.72.
 
 ## 0.6.1
diff --git a/actix-multipart/Cargo.toml b/actix-multipart/Cargo.toml
index 257d5613..6e36c339 100644
--- a/actix-multipart/Cargo.toml
+++ b/actix-multipart/Cargo.toml
@@ -35,6 +35,7 @@ local-waker = "0.1"
 log = "0.4"
 memchr = "2.5"
 mime = "0.3"
+rand = "0.8"
 serde = "1"
 serde_json = "1"
 serde_plain = "1"
@@ -46,7 +47,9 @@ actix-http = "3"
 actix-multipart-rfc7578 = "0.10"
 actix-rt = "2.2"
 actix-test = "0.1"
+actix-web = "4"
 awc = "3"
 futures-util = { version = "0.3.17", default-features = false, features = ["alloc"] }
+multer = "3"
 tokio = { version = "1.24.2", features = ["sync"] }
 tokio-stream = "0.1"
diff --git a/actix-multipart/src/form/json.rs b/actix-multipart/src/form/json.rs
index fb90a82b..bb4e03bf 100644
--- a/actix-multipart/src/form/json.rs
+++ b/actix-multipart/src/form/json.rs
@@ -131,14 +131,13 @@ impl Default for JsonConfig {
 
 #[cfg(test)]
 mod tests {
-    use std::{collections::HashMap, io::Cursor};
+    use std::collections::HashMap;
 
-    use actix_multipart_rfc7578::client::multipart;
     use actix_web::{http::StatusCode, web, App, HttpResponse, Responder};
+    use bytes::Bytes;
 
     use crate::form::{
         json::{Json, JsonConfig},
-        tests::send_form,
         MultipartForm,
     };
 
@@ -155,6 +154,8 @@ mod tests {
         HttpResponse::Ok().finish()
     }
 
+    const TEST_JSON: &str = r#"{"key1": "value1", "key2": "value2"}"#;
+
     #[actix_rt::test]
     async fn test_json_without_content_type() {
         let srv = actix_test::start(|| {
@@ -163,10 +164,16 @@ mod tests {
                 .app_data(JsonConfig::default().validate_content_type(false))
         });
 
-        let mut form = multipart::Form::default();
-        form.add_text("json", "{\"key1\": \"value1\", \"key2\": \"value2\"}");
-        let response = send_form(&srv, form, "/").await;
-        assert_eq!(response.status(), StatusCode::OK);
+        let (body, headers) = crate::test::create_form_data_payload_and_headers(
+            "json",
+            None,
+            None,
+            Bytes::from_static(TEST_JSON.as_bytes()),
+        );
+        let mut req = srv.post("/");
+        *req.headers_mut() = headers;
+        let res = req.send_body(body).await.unwrap();
+        assert_eq!(res.status(), StatusCode::OK);
     }
 
     #[actix_rt::test]
@@ -178,17 +185,27 @@ mod tests {
         });
 
         // Deny because wrong content type
-        let bytes = Cursor::new("{\"key1\": \"value1\", \"key2\": \"value2\"}");
-        let mut form = multipart::Form::default();
-        form.add_reader_file_with_mime("json", bytes, "", mime::APPLICATION_OCTET_STREAM);
-        let response = send_form(&srv, form, "/").await;
-        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+        let (body, headers) = crate::test::create_form_data_payload_and_headers(
+            "json",
+            None,
+            Some(mime::APPLICATION_OCTET_STREAM),
+            Bytes::from_static(TEST_JSON.as_bytes()),
+        );
+        let mut req = srv.post("/");
+        *req.headers_mut() = headers;
+        let res = req.send_body(body).await.unwrap();
+        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
 
         // Allow because correct content type
-        let bytes = Cursor::new("{\"key1\": \"value1\", \"key2\": \"value2\"}");
-        let mut form = multipart::Form::default();
-        form.add_reader_file_with_mime("json", bytes, "", mime::APPLICATION_JSON);
-        let response = send_form(&srv, form, "/").await;
-        assert_eq!(response.status(), StatusCode::OK);
+        let (body, headers) = crate::test::create_form_data_payload_and_headers(
+            "json",
+            None,
+            Some(mime::APPLICATION_JSON),
+            Bytes::from_static(TEST_JSON.as_bytes()),
+        );
+        let mut req = srv.post("/");
+        *req.headers_mut() = headers;
+        let res = req.send_body(body).await.unwrap();
+        assert_eq!(res.status(), StatusCode::OK);
     }
 }
diff --git a/actix-multipart/src/lib.rs b/actix-multipart/src/lib.rs
index 495bae9c..c06a00ca 100644
--- a/actix-multipart/src/lib.rs
+++ b/actix-multipart/src/lib.rs
@@ -13,11 +13,14 @@ extern crate self as actix_multipart;
 
 mod error;
 mod extractor;
-mod server;
-
 pub mod form;
+mod server;
+pub mod test;
 
 pub use self::{
     error::MultipartError,
     server::{Field, Multipart},
+    test::{
+        create_form_data_payload_and_headers, create_form_data_payload_and_headers_with_boundary,
+    },
 };
diff --git a/actix-multipart/src/server.rs b/actix-multipart/src/server.rs
index c08031eb..ae32cf0c 100644
--- a/actix-multipart/src/server.rs
+++ b/actix-multipart/src/server.rs
@@ -863,13 +863,15 @@ mod tests {
         test::TestRequest,
         FromRequest,
     };
-    use bytes::Bytes;
+    use bytes::{BufMut as _, Bytes};
     use futures_util::{future::lazy, StreamExt as _};
     use tokio::sync::mpsc;
     use tokio_stream::wrappers::UnboundedReceiverStream;
 
     use super::*;
 
+    const BOUNDARY: &str = "abbc761f78ff4d7cb7573b5a23f96ef0";
+
     #[actix_rt::test]
     async fn test_boundary() {
         let headers = HeaderMap::new();
@@ -965,6 +967,26 @@ mod tests {
     }
 
     fn create_simple_request_with_header() -> (Bytes, HeaderMap) {
+        let (body, headers) = crate::test::create_form_data_payload_and_headers_with_boundary(
+            BOUNDARY,
+            "file",
+            Some("fn.txt".to_owned()),
+            Some(mime::TEXT_PLAIN_UTF_8),
+            Bytes::from_static(b"data"),
+        );
+
+        let mut buf = BytesMut::with_capacity(body.len() + 14);
+
+        // add junk before form to test pre-boundary data rejection
+        buf.put("testasdadsad\r\n".as_bytes());
+
+        buf.put(body);
+
+        (buf.freeze(), headers)
+    }
+
+    // TODO: use test utility when multi-file support is introduced
+    fn create_double_request_with_header() -> (Bytes, HeaderMap) {
         let bytes = Bytes::from(
             "testasdadsad\r\n\
              --abbc761f78ff4d7cb7573b5a23f96ef0\r\n\
@@ -990,7 +1012,7 @@ mod tests {
     #[actix_rt::test]
     async fn test_multipart_no_end_crlf() {
         let (sender, payload) = create_stream();
-        let (mut bytes, headers) = create_simple_request_with_header();
+        let (mut bytes, headers) = create_double_request_with_header();
         let bytes_stripped = bytes.split_to(bytes.len()); // strip crlf
 
         sender.send(Ok(bytes_stripped)).unwrap();
@@ -1017,7 +1039,7 @@ mod tests {
     #[actix_rt::test]
     async fn test_multipart() {
         let (sender, payload) = create_stream();
-        let (bytes, headers) = create_simple_request_with_header();
+        let (bytes, headers) = create_double_request_with_header();
 
         sender.send(Ok(bytes)).unwrap();
 
@@ -1080,7 +1102,7 @@ mod tests {
 
     #[actix_rt::test]
     async fn test_stream() {
-        let (bytes, headers) = create_simple_request_with_header();
+        let (bytes, headers) = create_double_request_with_header();
         let payload = SlowStream::new(bytes);
 
         let mut multipart = Multipart::new(&headers, payload);
@@ -1319,7 +1341,7 @@ mod tests {
     #[actix_rt::test]
     async fn test_drop_field_awaken_multipart() {
         let (sender, payload) = create_stream();
-        let (bytes, headers) = create_simple_request_with_header();
+        let (bytes, headers) = create_double_request_with_header();
         sender.send(Ok(bytes)).unwrap();
         drop(sender); // eof
 
diff --git a/actix-multipart/src/test.rs b/actix-multipart/src/test.rs
new file mode 100644
index 00000000..77d91828
--- /dev/null
+++ b/actix-multipart/src/test.rs
@@ -0,0 +1,217 @@
+use actix_web::http::header::{self, HeaderMap};
+use bytes::{BufMut as _, Bytes, BytesMut};
+use mime::Mime;
+use rand::{
+    distributions::{Alphanumeric, DistString as _},
+    thread_rng,
+};
+
+const CRLF: &[u8] = b"\r\n";
+const CRLF_CRLF: &[u8] = b"\r\n\r\n";
+const HYPHENS: &[u8] = b"--";
+const BOUNDARY_PREFIX: &str = "------------------------";
+
+/// Constructs a `multipart/form-data` payload from bytes and metadata.
+///
+/// Returned header map can be extended or merged with existing headers.
+///
+/// Multipart boundary used is a random alphanumeric string.
+///
+/// # Examples
+///
+/// ```
+/// use actix_multipart::test::create_form_data_payload_and_headers;
+/// use actix_web::test::TestRequest;
+/// use bytes::Bytes;
+/// use memchr::memmem::find;
+///
+/// let (body, headers) = create_form_data_payload_and_headers(
+///     "foo",
+///     Some("lorem.txt".to_owned()),
+///     Some(mime::TEXT_PLAIN_UTF_8),
+///     Bytes::from_static(b"Lorem ipsum."),
+/// );
+///
+/// assert!(find(&body, b"foo").is_some());
+/// assert!(find(&body, b"lorem.txt").is_some());
+/// assert!(find(&body, b"text/plain; charset=utf-8").is_some());
+/// assert!(find(&body, b"Lorem ipsum.").is_some());
+///
+/// let req = TestRequest::default();
+///
+/// // merge header map into existing test request and set multipart body
+/// let req = headers
+///     .into_iter()
+///     .fold(req, |req, hdr| req.insert_header(hdr))
+///     .set_payload(body)
+///     .to_http_request();
+///
+/// assert!(
+///     req.headers()
+///         .get("content-type")
+///         .unwrap()
+///         .to_str()
+///         .unwrap()
+///         .starts_with("multipart/form-data; boundary=\"")
+/// );
+/// ```
+pub fn create_form_data_payload_and_headers(
+    name: &str,
+    filename: Option<String>,
+    content_type: Option<Mime>,
+    file: Bytes,
+) -> (Bytes, HeaderMap) {
+    let boundary = Alphanumeric.sample_string(&mut thread_rng(), 32);
+
+    create_form_data_payload_and_headers_with_boundary(
+        &boundary,
+        name,
+        filename,
+        content_type,
+        file,
+    )
+}
+
+/// Constructs a `multipart/form-data` payload from bytes and metadata with a fixed boundary.
+///
+/// See [`create_form_data_payload_and_headers`] for more details.
+pub fn create_form_data_payload_and_headers_with_boundary(
+    boundary: &str,
+    name: &str,
+    filename: Option<String>,
+    content_type: Option<Mime>,
+    file: Bytes,
+) -> (Bytes, HeaderMap) {
+    let mut buf = BytesMut::with_capacity(file.len() + 128);
+
+    let boundary_str = [BOUNDARY_PREFIX, boundary].concat();
+    let boundary = boundary_str.as_bytes();
+
+    buf.put(HYPHENS);
+    buf.put(boundary);
+    buf.put(CRLF);
+
+    buf.put(format!("Content-Disposition: form-data; name=\"{name}\"").as_bytes());
+    if let Some(filename) = filename {
+        buf.put(format!("; filename=\"{filename}\"").as_bytes());
+    }
+    buf.put(CRLF);
+
+    if let Some(ct) = content_type {
+        buf.put(format!("Content-Type: {ct}").as_bytes());
+        buf.put(CRLF);
+    }
+
+    buf.put(format!("Content-Length: {}", file.len()).as_bytes());
+    buf.put(CRLF_CRLF);
+
+    buf.put(file);
+    buf.put(CRLF);
+
+    buf.put(HYPHENS);
+    buf.put(boundary);
+    buf.put(HYPHENS);
+    buf.put(CRLF);
+
+    let mut headers = HeaderMap::new();
+    headers.insert(
+        header::CONTENT_TYPE,
+        format!("multipart/form-data; boundary=\"{boundary_str}\"")
+            .parse()
+            .unwrap(),
+    );
+
+    (buf.freeze(), headers)
+}
+
+#[cfg(test)]
+mod tests {
+    use std::convert::Infallible;
+
+    use futures_util::stream;
+
+    use super::*;
+
+    fn find_boundary(headers: &HeaderMap) -> String {
+        headers
+            .get("content-type")
+            .unwrap()
+            .to_str()
+            .unwrap()
+            .parse::<mime::Mime>()
+            .unwrap()
+            .get_param(mime::BOUNDARY)
+            .unwrap()
+            .as_str()
+            .to_owned()
+    }
+
+    #[test]
+    fn wire_format() {
+        let (pl, headers) = create_form_data_payload_and_headers_with_boundary(
+            "qWeRtYuIoP",
+            "foo",
+            None,
+            None,
+            Bytes::from_static(b"Lorem ipsum dolor\nsit ame."),
+        );
+
+        assert_eq!(
+            find_boundary(&headers),
+            "------------------------qWeRtYuIoP",
+        );
+
+        assert_eq!(
+            std::str::from_utf8(&pl).unwrap(),
+            "--------------------------qWeRtYuIoP\r\n\
+            Content-Disposition: form-data; name=\"foo\"\r\n\
+            Content-Length: 26\r\n\
+            \r\n\
+            Lorem ipsum dolor\n\
+            sit ame.\r\n\
+            --------------------------qWeRtYuIoP--\r\n",
+        );
+
+        let (pl, _headers) = create_form_data_payload_and_headers_with_boundary(
+            "qWeRtYuIoP",
+            "foo",
+            Some("Lorem.txt".to_owned()),
+            Some(mime::TEXT_PLAIN_UTF_8),
+            Bytes::from_static(b"Lorem ipsum dolor\nsit ame."),
+        );
+
+        assert_eq!(
+            std::str::from_utf8(&pl).unwrap(),
+            "--------------------------qWeRtYuIoP\r\n\
+            Content-Disposition: form-data; name=\"foo\"; filename=\"Lorem.txt\"\r\n\
+            Content-Type: text/plain; charset=utf-8\r\n\
+            Content-Length: 26\r\n\
+            \r\n\
+            Lorem ipsum dolor\n\
+            sit ame.\r\n\
+            --------------------------qWeRtYuIoP--\r\n",
+        );
+    }
+
+    /// Test using an external library to prevent the two-wrongs-make-a-right class of errors.
+    #[actix_web::test]
+    async fn ecosystem_compat() {
+        let (pl, headers) = create_form_data_payload_and_headers(
+            "foo",
+            None,
+            None,
+            Bytes::from_static(b"Lorem ipsum dolor\nsit ame."),
+        );
+
+        let boundary = find_boundary(&headers);
+
+        let pl = stream::once(async { Ok::<_, Infallible>(pl) });
+
+        let mut form = multer::Multipart::new(pl, boundary);
+        let field = form.next_field().await.unwrap().unwrap();
+        assert_eq!(field.name().unwrap(), "foo");
+        assert_eq!(field.file_name(), None);
+        assert_eq!(field.content_type(), None);
+        assert!(field.bytes().await.unwrap().starts_with(b"Lorem"));
+    }
+}