Skip to main content

tablo_test/
lib.rs

1//! Drives a built `Router` without a socket, carrying cookies, CSRF token, and
2//! tenant, and reads values back out of its responses.
3
4mod protocol;
5
6use http::header::{CONTENT_TYPE, COOKIE};
7pub use protocol::{
8    EmptyTable, FilterOption, Row, RowActions, SESSION_COOKIE, body_bytes, body_string,
9    cookie_header, empty_table, field_error, filter_options, form_body, input_value,
10    multipart_body, notification, response_cookies, row_actions, rows, session_cookie_value,
11    set_cookie_header,
12};
13use tablo_core::PanelUser;
14use topcoat::router::{Body, Router};
15
16/// Sends requests carrying cookies and tenant.
17#[derive(Clone)]
18pub struct TestClient<'a> {
19    router: &'a Router,
20    cookies: Vec<(String, String)>,
21    tenant: Option<uuid::Uuid>,
22}
23
24impl<'a> TestClient<'a> {
25    pub fn new(router: &'a Router) -> Self {
26        Self {
27            router,
28            cookies: Vec::new(),
29            tenant: None,
30        }
31    }
32
33    /// Attaches a cookie to every request; a later value for the same name
34    /// replaces the earlier one.
35    pub fn cookie(&self, name: &str, value: &str) -> Self {
36        let mut client = self.clone();
37        match client.cookies.iter_mut().find(|(kept, _)| kept == name) {
38            Some(existing) => existing.1 = value.to_string(),
39            None => client.cookies.push((name.to_string(), value.to_string())),
40        }
41        client
42    }
43
44    /// Attaches every `(name, value)` pair, replacing earlier values for the
45    /// same name.
46    pub fn cookies(&self, cookies: &[(String, String)]) -> Self {
47        let mut client = self.clone();
48        for (name, value) in cookies {
49            match client.cookies.iter_mut().find(|(kept, _)| kept == name) {
50                Some(existing) => existing.1 = value.clone(),
51                None => client.cookies.push((name.clone(), value.clone())),
52            }
53        }
54        client
55    }
56
57    /// Attach the CSRF cookie the form's `csrf_token` field must match.
58    pub fn csrf(&self, token: &str) -> Self {
59        self.cookie(tablo_core::csrf::COOKIE_NAME, token)
60    }
61
62    /// Signs `user` in to the panel `Panel::new(panel)` mounts, as a successful login does, and
63    /// attaches the session cookie; no password is hashed or checked.
64    ///
65    /// The session row goes into `db`, the router's database, keyed to [`PanelUser::user_id`]:
66    /// each request loads the user back through the panel's `Authenticator::find_by_id`, so a
67    /// custom authenticator works as the shipped one does.
68    ///
69    /// ```rust,no_run
70    /// # async fn sign_in(router: &topcoat::router::Router, mut db: toasty::Db) {
71    /// # use tablo_test::TestClient;
72    /// let admin = tablo_core::auth::create_admin(&mut db, "admin@example.com", "secret", "Admin")
73    ///     .await
74    ///     .expect("seed the admin");
75    /// let client = TestClient::new(router).sign_in(&db, "admin", &admin).await;
76    /// # let _ = client;
77    /// # }
78    /// ```
79    ///
80    /// # Panics
81    ///
82    /// When the session row cannot be written, e.g. the database does not register
83    /// `AuthSession`.
84    ///
85    /// [`PanelUser::user_id`]: tablo_core::PanelUser::user_id
86    pub async fn sign_in(&self, db: &toasty::Db, panel: &str, user: &impl PanelUser) -> Self {
87        let token = tablo_core::auth::mint_session(&mut db.clone(), panel, user)
88            .await
89            .expect("record the test session");
90        self.cookie(SESSION_COOKIE, &token)
91    }
92
93    /// Scopes the request to another tenant, taking precedence over the
94    /// logged-in user's tenant.
95    pub fn tenant(&self, tenant: uuid::Uuid) -> Self {
96        let mut client = self.clone();
97        client.tenant = Some(tenant);
98        client
99    }
100
101    pub async fn get(&self, uri: &str) -> http::Response<Body> {
102        self.router
103            .handle(self.request(http::Method::GET, uri))
104            .await
105    }
106
107    pub async fn post_form(&self, uri: &str, body: String) -> http::Response<Body> {
108        let mut request = self.request(http::Method::POST, uri);
109        request.headers_mut().insert(
110            CONTENT_TYPE,
111            http::HeaderValue::from_static("application/x-www-form-urlencoded"),
112        );
113        *request.body_mut() = Body::from(body);
114        self.router.handle(request).await
115    }
116
117    /// Posts the urlencoded `body` as a panel form does: with a fresh CSRF token in both the
118    /// cookie and the `csrf_token` field.
119    pub async fn submit(&self, uri: &str, body: &str) -> http::Response<Body> {
120        let token = uuid::Uuid::new_v4().to_string();
121        let field = form_body(&[("csrf_token", &token)]);
122        let body = if body.is_empty() {
123            field
124        } else {
125            format!("{body}&{field}")
126        };
127        self.csrf(&token).post_form(uri, body).await
128    }
129
130    pub async fn post_multipart(
131        &self,
132        uri: &str,
133        boundary: &str,
134        body: String,
135    ) -> http::Response<Body> {
136        let mut request = self.request(http::Method::POST, uri);
137        request.headers_mut().insert(
138            CONTENT_TYPE,
139            format!("multipart/form-data; boundary={boundary}")
140                .parse()
141                .expect("multipart content type"),
142        );
143        *request.body_mut() = Body::from(body);
144        self.router.handle(request).await
145    }
146
147    /// Posts JSON with the page identity header the browser runtime sends.
148    pub async fn post_json(&self, uri: &str, body: String, identity: &str) -> http::Response<Body> {
149        let mut request = self.request(http::Method::POST, uri);
150        request.headers_mut().insert(
151            CONTENT_TYPE,
152            http::HeaderValue::from_static("application/json"),
153        );
154        request.headers_mut().insert(
155            topcoat::router::request::IDENTITY_HEADER,
156            http::HeaderValue::from_str(identity).expect("identity header"),
157        );
158        *request.body_mut() = Body::from(body);
159        self.router.handle(request).await
160    }
161
162    fn request(&self, method: http::Method, uri: &str) -> http::Request<Body> {
163        let mut builder = http::Request::builder().method(method).uri(uri);
164        if let Some(jar) = cookie_header(
165            self.cookies
166                .iter()
167                .map(|(name, value)| (name.as_str(), value.as_str())),
168        ) {
169            builder = builder.header(COOKIE, jar);
170        }
171        let (mut parts, body) = builder.body(Body::empty()).unwrap().into_parts();
172        if let Some(tenant) = self.tenant {
173            parts.extensions.insert(tablo_core::Tenant(tenant));
174        }
175        http::Request::from_parts(parts, body)
176    }
177}