Skip to main content

tablo_test/
lib.rs

1//! Drives a built `Router` without a socket, carrying cookies, CSRF token, and
2//! tenant; helpers write form and multipart bodies and read values back out of
3//! rendered HTML.
4
5mod dom;
6
7pub use dom::{FilterOption, Row, RowActions, field_error, filter_options, row_actions, rows};
8use http::header::{CONTENT_TYPE, COOKIE};
9use http_body_util::BodyExt;
10use topcoat::router::{Body, Router};
11
12/// Sends requests carrying cookies and tenant.
13#[derive(Clone)]
14pub struct TestClient<'a> {
15    router: &'a Router,
16    cookies: Vec<(String, String)>,
17    tenant: Option<uuid::Uuid>,
18}
19
20impl<'a> TestClient<'a> {
21    pub fn new(router: &'a Router) -> Self {
22        Self {
23            router,
24            cookies: Vec::new(),
25            tenant: None,
26        }
27    }
28
29    /// Attaches a cookie to every request; a later value for the same name
30    /// replaces the earlier one.
31    pub fn cookie(&self, name: &str, value: &str) -> Self {
32        let mut client = self.clone();
33        match client.cookies.iter_mut().find(|(kept, _)| kept == name) {
34            Some(existing) => existing.1 = value.to_string(),
35            None => client.cookies.push((name.to_string(), value.to_string())),
36        }
37        client
38    }
39
40    /// Attaches every `(name, value)` pair, replacing earlier values for the
41    /// same name.
42    pub fn cookies(&self, cookies: &[(String, String)]) -> Self {
43        let mut client = self.clone();
44        for (name, value) in cookies {
45            match client.cookies.iter_mut().find(|(kept, _)| kept == name) {
46                Some(existing) => existing.1 = value.clone(),
47                None => client.cookies.push((name.clone(), value.clone())),
48            }
49        }
50        client
51    }
52
53    /// Attach the CSRF cookie the form's `csrf_token` field must match.
54    pub fn csrf(&self, token: &str) -> Self {
55        self.cookie(tablo_core::csrf::COOKIE_NAME, token)
56    }
57
58    /// Scopes the request to another tenant, taking precedence over the
59    /// logged-in user's tenant.
60    pub fn tenant(&self, tenant: uuid::Uuid) -> Self {
61        let mut client = self.clone();
62        client.tenant = Some(tenant);
63        client
64    }
65
66    pub async fn get(&self, uri: &str) -> http::Response<Body> {
67        self.router
68            .handle(self.request(http::Method::GET, uri))
69            .await
70    }
71
72    pub async fn post_form(&self, uri: &str, body: String) -> http::Response<Body> {
73        let mut request = self.request(http::Method::POST, uri);
74        request.headers_mut().insert(
75            CONTENT_TYPE,
76            http::HeaderValue::from_static("application/x-www-form-urlencoded"),
77        );
78        *request.body_mut() = Body::from(body);
79        self.router.handle(request).await
80    }
81
82    pub async fn post_multipart(
83        &self,
84        uri: &str,
85        boundary: &str,
86        body: String,
87    ) -> http::Response<Body> {
88        let mut request = self.request(http::Method::POST, uri);
89        request.headers_mut().insert(
90            CONTENT_TYPE,
91            format!("multipart/form-data; boundary={boundary}")
92                .parse()
93                .expect("multipart content type"),
94        );
95        *request.body_mut() = Body::from(body);
96        self.router.handle(request).await
97    }
98
99    /// Posts JSON with the page identity header the browser runtime sends.
100    pub async fn post_json(&self, uri: &str, body: String, identity: &str) -> http::Response<Body> {
101        let mut request = self.request(http::Method::POST, uri);
102        request.headers_mut().insert(
103            CONTENT_TYPE,
104            http::HeaderValue::from_static("application/json"),
105        );
106        request.headers_mut().insert(
107            topcoat::router::request::IDENTITY_HEADER,
108            http::HeaderValue::from_str(identity).expect("identity header"),
109        );
110        *request.body_mut() = Body::from(body);
111        self.router.handle(request).await
112    }
113
114    fn request(&self, method: http::Method, uri: &str) -> http::Request<Body> {
115        let mut builder = http::Request::builder().method(method).uri(uri);
116        if let Some(jar) = cookie_header(
117            self.cookies
118                .iter()
119                .map(|(name, value)| (name.as_str(), value.as_str())),
120        ) {
121            builder = builder.header(COOKIE, jar);
122        }
123        let (mut parts, body) = builder.body(Body::empty()).unwrap().into_parts();
124        if let Some(tenant) = self.tenant {
125            parts.extensions.insert(tablo_core::Tenant(tenant));
126        }
127        http::Request::from_parts(parts, body)
128    }
129}
130
131/// One `Cookie` header value from `(name, value)` pairs, or `None` when empty.
132pub fn cookie_header<'a>(cookies: impl IntoIterator<Item = (&'a str, &'a str)>) -> Option<String> {
133    let mut jar = String::new();
134    for (name, value) in cookies {
135        if !jar.is_empty() {
136            jar.push_str("; ");
137        }
138        jar.push_str(name);
139        jar.push('=');
140        jar.push_str(value);
141    }
142    (!jar.is_empty()).then_some(jar)
143}
144
145/// Collect a response body as bytes.
146pub async fn body_bytes(response: http::Response<Body>) -> Vec<u8> {
147    response
148        .into_body()
149        .collect()
150        .await
151        .expect("collect body")
152        .to_bytes()
153        .to_vec()
154}
155
156/// Collect a response body as a lossy UTF-8 string.
157pub async fn body_string(response: http::Response<Body>) -> String {
158    String::from_utf8_lossy(&body_bytes(response).await).into_owned()
159}
160
161/// URL-encode `(key, value)` pairs into an urlencoded form body.
162pub fn form_body(pairs: &[(&str, &str)]) -> String {
163    let mut serializer = form_urlencoded::Serializer::new(String::new());
164    for (key, value) in pairs {
165        serializer.append_pair(key, value);
166    }
167    serializer.finish()
168}
169
170/// Builds a multipart body, one part per entry: `None` is a text part,
171/// `Some("")` the browser's "no file chosen" part, `Some(name)` a chosen file.
172pub fn multipart_body(boundary: &str, parts: &[(&str, Option<&str>, &str)]) -> String {
173    let mut body = String::new();
174    for (name, filename, content) in parts {
175        body.push_str(&format!(
176            "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\""
177        ));
178        if let Some(filename) = filename {
179            body.push_str(&format!("; filename=\"{filename}\""));
180        }
181        body.push_str("\r\n\r\n");
182        body.push_str(content);
183        body.push_str("\r\n");
184    }
185    body.push_str(&format!("--{boundary}--\r\n"));
186    body
187}
188
189/// Reads the `value` of the named `<input>` in rendered HTML, in either
190/// attribute order or quote style.
191pub fn input_value(html: &str, name: &str) -> Option<String> {
192    let double = format!("name=\"{name}\"");
193    let single = format!("name='{name}'");
194    for tag in html.split('<').skip(1) {
195        if !tag.contains(&double) && !tag.contains(&single) {
196            continue;
197        }
198        let attrs = &tag[..tag.find('>')?];
199        for (prefix, term) in [("value=\"", '"'), ("value='", '\'')] {
200            if let Some(start) = attrs.find(prefix) {
201                let rest = &attrs[start + prefix.len()..];
202                if let Some(end) = rest.find(term) {
203                    // Unescape the matching quote entity the encoder may emit.
204                    let raw = &rest[..end];
205                    return Some(raw.replace("&quot;", "\"").replace("&#x27;", "'"));
206                }
207            }
208        }
209    }
210    None
211}
212
213/// Names the session cookie Topcoat's default token store writes.
214pub const SESSION_COOKIE: &str = "__Host-session";
215
216/// The `(name, value)` pairs a response's `Set-Cookie` headers carry.
217pub fn response_cookies(response: &http::Response<Body>) -> Vec<(String, String)> {
218    response
219        .headers()
220        .get_all(http::header::SET_COOKIE)
221        .iter()
222        .filter_map(|value| value.to_str().ok())
223        .filter_map(|value| value.split(';').next())
224        .filter_map(|pair| pair.split_once('='))
225        .map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
226        .collect()
227}
228
229/// The full `Set-Cookie` header for `name`, so tests can assert attributes.
230pub fn set_cookie_header(response: &http::Response<Body>, name: &str) -> Option<String> {
231    response
232        .headers()
233        .get_all(http::header::SET_COOKIE)
234        .iter()
235        .filter_map(|value| value.to_str().ok())
236        .find(|value| value.starts_with(&format!("{name}=")))
237        .map(str::to_string)
238}
239
240/// The session cookie value a response set, if any.
241pub fn session_cookie_value(response: &http::Response<Body>) -> Option<String> {
242    response_cookies(response)
243        .into_iter()
244        .find(|(name, _)| name == SESSION_COOKIE)
245        .map(|(_, value)| value)
246}