actix_multipart/
test.rs

1//! Multipart testing utilities.
2
3use actix_web::{
4    http::header::{self, HeaderMap},
5    web::{BufMut as _, Bytes, BytesMut},
6};
7use mime::Mime;
8use rand::{
9    distributions::{Alphanumeric, DistString as _},
10    thread_rng,
11};
12
13const CRLF: &[u8] = b"\r\n";
14const CRLF_CRLF: &[u8] = b"\r\n\r\n";
15const HYPHENS: &[u8] = b"--";
16const BOUNDARY_PREFIX: &str = "------------------------";
17
18/// Constructs a `multipart/form-data` payload from bytes and metadata.
19///
20/// Returned header map can be extended or merged with existing headers.
21///
22/// Multipart boundary used is a random alphanumeric string.
23///
24/// # Examples
25///
26/// ```
27/// use actix_multipart::test::create_form_data_payload_and_headers;
28/// use actix_web::{test::TestRequest, web::Bytes};
29/// use memchr::memmem::find;
30///
31/// let (body, headers) = create_form_data_payload_and_headers(
32///     "foo",
33///     Some("lorem.txt".to_owned()),
34///     Some(mime::TEXT_PLAIN_UTF_8),
35///     Bytes::from_static(b"Lorem ipsum."),
36/// );
37///
38/// assert!(find(&body, b"foo").is_some());
39/// assert!(find(&body, b"lorem.txt").is_some());
40/// assert!(find(&body, b"text/plain; charset=utf-8").is_some());
41/// assert!(find(&body, b"Lorem ipsum.").is_some());
42///
43/// let req = TestRequest::default();
44///
45/// // merge header map into existing test request and set multipart body
46/// let req = headers
47///     .into_iter()
48///     .fold(req, |req, hdr| req.insert_header(hdr))
49///     .set_payload(body)
50///     .to_http_request();
51///
52/// assert!(
53///     req.headers()
54///         .get("content-type")
55///         .unwrap()
56///         .to_str()
57///         .unwrap()
58///         .starts_with("multipart/form-data; boundary=\"")
59/// );
60/// ```
61pub fn create_form_data_payload_and_headers(
62    name: &str,
63    filename: Option<String>,
64    content_type: Option<Mime>,
65    file: Bytes,
66) -> (Bytes, HeaderMap) {
67    let boundary = Alphanumeric.sample_string(&mut thread_rng(), 32);
68
69    create_form_data_payload_and_headers_with_boundary(
70        &boundary,
71        name,
72        filename,
73        content_type,
74        file,
75    )
76}
77
78/// Constructs a `multipart/form-data` payload from bytes and metadata with a fixed boundary.
79///
80/// See [`create_form_data_payload_and_headers`] for more details.
81pub fn create_form_data_payload_and_headers_with_boundary(
82    boundary: &str,
83    name: &str,
84    filename: Option<String>,
85    content_type: Option<Mime>,
86    file: Bytes,
87) -> (Bytes, HeaderMap) {
88    let mut buf = BytesMut::with_capacity(file.len() + 128);
89
90    let boundary_str = [BOUNDARY_PREFIX, boundary].concat();
91    let boundary = boundary_str.as_bytes();
92
93    buf.put(HYPHENS);
94    buf.put(boundary);
95    buf.put(CRLF);
96
97    buf.put(format!("Content-Disposition: form-data; name=\"{name}\"").as_bytes());
98    if let Some(filename) = filename {
99        buf.put(format!("; filename=\"{filename}\"").as_bytes());
100    }
101    buf.put(CRLF);
102
103    if let Some(ct) = content_type {
104        buf.put(format!("Content-Type: {ct}").as_bytes());
105        buf.put(CRLF);
106    }
107
108    buf.put(format!("Content-Length: {}", file.len()).as_bytes());
109    buf.put(CRLF_CRLF);
110
111    buf.put(file);
112    buf.put(CRLF);
113
114    buf.put(HYPHENS);
115    buf.put(boundary);
116    buf.put(HYPHENS);
117    buf.put(CRLF);
118
119    let mut headers = HeaderMap::new();
120    headers.insert(
121        header::CONTENT_TYPE,
122        format!("multipart/form-data; boundary=\"{boundary_str}\"")
123            .parse()
124            .unwrap(),
125    );
126
127    (buf.freeze(), headers)
128}
129
130#[cfg(test)]
131mod tests {
132    use std::convert::Infallible;
133
134    use futures_util::stream;
135
136    use super::*;
137
138    fn find_boundary(headers: &HeaderMap) -> String {
139        headers
140            .get("content-type")
141            .unwrap()
142            .to_str()
143            .unwrap()
144            .parse::<mime::Mime>()
145            .unwrap()
146            .get_param(mime::BOUNDARY)
147            .unwrap()
148            .as_str()
149            .to_owned()
150    }
151
152    #[test]
153    fn wire_format() {
154        let (pl, headers) = create_form_data_payload_and_headers_with_boundary(
155            "qWeRtYuIoP",
156            "foo",
157            None,
158            None,
159            Bytes::from_static(b"Lorem ipsum dolor\nsit ame."),
160        );
161
162        assert_eq!(
163            find_boundary(&headers),
164            "------------------------qWeRtYuIoP",
165        );
166
167        assert_eq!(
168            std::str::from_utf8(&pl).unwrap(),
169            "--------------------------qWeRtYuIoP\r\n\
170            Content-Disposition: form-data; name=\"foo\"\r\n\
171            Content-Length: 26\r\n\
172            \r\n\
173            Lorem ipsum dolor\n\
174            sit ame.\r\n\
175            --------------------------qWeRtYuIoP--\r\n",
176        );
177
178        let (pl, _headers) = create_form_data_payload_and_headers_with_boundary(
179            "qWeRtYuIoP",
180            "foo",
181            Some("Lorem.txt".to_owned()),
182            Some(mime::TEXT_PLAIN_UTF_8),
183            Bytes::from_static(b"Lorem ipsum dolor\nsit ame."),
184        );
185
186        assert_eq!(
187            std::str::from_utf8(&pl).unwrap(),
188            "--------------------------qWeRtYuIoP\r\n\
189            Content-Disposition: form-data; name=\"foo\"; filename=\"Lorem.txt\"\r\n\
190            Content-Type: text/plain; charset=utf-8\r\n\
191            Content-Length: 26\r\n\
192            \r\n\
193            Lorem ipsum dolor\n\
194            sit ame.\r\n\
195            --------------------------qWeRtYuIoP--\r\n",
196        );
197    }
198
199    /// Test using an external library to prevent the two-wrongs-make-a-right class of errors.
200    #[actix_web::test]
201    async fn ecosystem_compat() {
202        let (pl, headers) = create_form_data_payload_and_headers(
203            "foo",
204            None,
205            None,
206            Bytes::from_static(b"Lorem ipsum dolor\nsit ame."),
207        );
208
209        let boundary = find_boundary(&headers);
210
211        let pl = stream::once(async { Ok::<_, Infallible>(pl) });
212
213        let mut form = multer::Multipart::new(pl, boundary);
214        let field = form.next_field().await.unwrap().unwrap();
215        assert_eq!(field.name().unwrap(), "foo");
216        assert_eq!(field.file_name(), None);
217        assert_eq!(field.content_type(), None);
218        assert!(field.bytes().await.unwrap().starts_with(b"Lorem"));
219    }
220}