Skip to main content

tablo_test/
protocol.rs

1//! Reads values back out of rendered responses: bodies, cookies, form data,
2//! and the semantic HTML queries over tables, fields, and filters.
3
4pub mod dom;
5
6pub use dom::{
7    EmptyTable, FilterOption, Row, RowActions, empty_table, field_error, filter_options,
8    row_actions, rows,
9};
10use http_body_util::BodyExt;
11use tablo_core::Notification;
12use topcoat::router::Body;
13
14/// One `Cookie` header value from `(name, value)` pairs, or `None` when empty.
15pub fn cookie_header<'a>(cookies: impl IntoIterator<Item = (&'a str, &'a str)>) -> Option<String> {
16    let mut jar = String::new();
17    for (name, value) in cookies {
18        if !jar.is_empty() {
19            jar.push_str("; ");
20        }
21        jar.push_str(name);
22        jar.push('=');
23        jar.push_str(value);
24    }
25    (!jar.is_empty()).then_some(jar)
26}
27
28/// Collect a response body as bytes.
29pub async fn body_bytes(response: http::Response<Body>) -> Vec<u8> {
30    response
31        .into_body()
32        .collect()
33        .await
34        .expect("collect body")
35        .to_bytes()
36        .to_vec()
37}
38
39/// Collect a response body as a lossy UTF-8 string.
40pub async fn body_string(response: http::Response<Body>) -> String {
41    String::from_utf8_lossy(&body_bytes(response).await).into_owned()
42}
43
44/// URL-encode `(key, value)` pairs into an urlencoded form body.
45pub fn form_body(pairs: &[(&str, &str)]) -> String {
46    let mut serializer = form_urlencoded::Serializer::new(String::new());
47    for (key, value) in pairs {
48        serializer.append_pair(key, value);
49    }
50    serializer.finish()
51}
52
53/// Builds a multipart body, one part per entry: `None` is a text part,
54/// `Some("")` the browser's "no file chosen" part, `Some(name)` a chosen file.
55///
56/// No part carries a `Content-Type`: the panel tells file parts from text parts by the
57/// `filename` parameter alone. A test asserting on an upload's content type builds its own body.
58pub fn multipart_body(boundary: &str, parts: &[(&str, Option<&str>, &str)]) -> String {
59    let mut body = String::new();
60    for (name, filename, content) in parts {
61        body.push_str(&format!(
62            "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\""
63        ));
64        if let Some(filename) = filename {
65            body.push_str(&format!("; filename=\"{filename}\""));
66        }
67        body.push_str("\r\n\r\n");
68        body.push_str(content);
69        body.push_str("\r\n");
70    }
71    body.push_str(&format!("--{boundary}--\r\n"));
72    body
73}
74
75/// Reads the `value` of the named `<input>` in rendered HTML, in either
76/// attribute order or quote style.
77pub fn input_value(html: &str, name: &str) -> Option<String> {
78    let double = format!("name=\"{name}\"");
79    let single = format!("name='{name}'");
80    for tag in html.split('<').skip(1) {
81        if !tag.contains(&double) && !tag.contains(&single) {
82            continue;
83        }
84        let attrs = &tag[..tag.find('>')?];
85        for (prefix, term) in [("value=\"", '"'), ("value='", '\'')] {
86            if let Some(start) = attrs.find(prefix) {
87                let rest = &attrs[start + prefix.len()..];
88                if let Some(end) = rest.find(term) {
89                    // Unescape the matching quote entity the encoder may emit.
90                    let raw = &rest[..end];
91                    return Some(raw.replace("&quot;", "\"").replace("&#x27;", "'"));
92                }
93            }
94        }
95    }
96    None
97}
98
99/// Names the session cookie Topcoat's default token store writes.
100pub const SESSION_COOKIE: &str = "__Host-session";
101
102/// The `(name, value)` pairs a response's `Set-Cookie` headers carry.
103pub fn response_cookies(response: &http::Response<Body>) -> Vec<(String, String)> {
104    response
105        .headers()
106        .get_all(http::header::SET_COOKIE)
107        .iter()
108        .filter_map(|value| value.to_str().ok())
109        .filter_map(|value| value.split(';').next())
110        .filter_map(|pair| pair.split_once('='))
111        .map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
112        .collect()
113}
114
115/// The full `Set-Cookie` header for `name`, so tests can assert attributes.
116pub fn set_cookie_header(response: &http::Response<Body>, name: &str) -> Option<String> {
117    response
118        .headers()
119        .get_all(http::header::SET_COOKIE)
120        .iter()
121        .filter_map(|value| value.to_str().ok())
122        .find(|value| value.starts_with(&format!("{name}=")))
123        .map(str::to_string)
124}
125
126/// The session cookie value a response set, if any.
127pub fn session_cookie_value(response: &http::Response<Body>) -> Option<String> {
128    response_cookies(response)
129        .into_iter()
130        .find(|(name, _)| name == SESSION_COOKIE)
131        .map(|(_, value)| value)
132}
133
134/// The flash notification a response set for the next page, decoded.
135pub fn notification(response: &http::Response<Body>) -> Option<Notification> {
136    let (_, value) = response_cookies(response)
137        .into_iter()
138        .find(|(name, _)| name.ends_with("tablo_notification"))?;
139    let json = percent_encoding::percent_decode_str(&value)
140        .decode_utf8()
141        .ok()?;
142    serde_json::from_str(&json).ok()
143}