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, ErrorKind, FilterOption, Row, RowActions, SESSION_COOKIE, body_bytes, body_string,
9    confirms_first, cookie_header, empty_table, field_error, field_error_message, filter_options,
10    find_href_with, form_body, input_value, multipart_body, notification, response_cookies,
11    row_actions, row_keys, rows, session_cookie_value, set_cookie_header,
12};
13use tablo_core::{DeclarationError, MountError, Panel, PanelUser, RouterBuilderPanelExt};
14use topcoat::router::{Body, Router, RouterBuilderDiscoverExt};
15
16/// `panel` mounted on a router holding `db`, beside the app's own discovered pages and routes:
17/// the router an app builds, without its asset bundle.
18///
19/// # Errors
20///
21/// The [`MountError`] the panel refuses to mount with, which [`refusal`] lists.
22pub fn mount(db: toasty::Db, panel: Panel) -> topcoat::Result<Router> {
23    Ok(Router::builder()
24        .discover()
25        .app_context(db)
26        .panel(panel)?
27        .build())
28}
29
30/// The declaration mistakes a panel refused to mount with, for a test to match on their
31/// [`kind`](DeclarationError::kind).
32///
33/// ```rust,no_run
34/// # fn refused(db: toasty::Db, panel: tablo_core::Panel) {
35/// use tablo_test::{mount, refusal};
36///
37/// let mistakes = refusal(mount(db, panel));
38/// assert!(matches!(
39///     mistakes[0].kind,
40///     tablo_core::DeclarationErrorKind::MissingAuthModels { .. }
41/// ));
42/// # }
43/// ```
44///
45/// # Panics
46///
47/// When `mounted` succeeded, or failed with an error that is not a [`MountError`].
48pub fn refusal<T>(mounted: topcoat::Result<T>) -> Vec<DeclarationError> {
49    let Err(error) = mounted else {
50        panic!("the panel must not mount");
51    };
52    error
53        .downcast_ref::<MountError>()
54        .unwrap_or_else(|| panic!("a declaration mistake refuses the panel, got {error}"))
55        .errors()
56        .to_vec()
57}
58
59/// Sends requests carrying cookies and tenant.
60#[derive(Clone)]
61pub struct TestClient<'a> {
62    router: &'a Router,
63    cookies: Vec<(String, String)>,
64    tenant: Option<uuid::Uuid>,
65}
66
67impl<'a> TestClient<'a> {
68    pub fn new(router: &'a Router) -> Self {
69        Self {
70            router,
71            cookies: Vec::new(),
72            tenant: None,
73        }
74    }
75
76    /// Attaches a cookie to every request; a later value for the same name
77    /// replaces the earlier one.
78    pub fn cookie(&self, name: &str, value: &str) -> Self {
79        self.with_cookies(&[(name.to_string(), value.to_string())])
80    }
81
82    /// Attaches every `(name, value)` pair, replacing earlier values for the
83    /// same name.
84    pub fn cookies(&self, cookies: &[(String, String)]) -> Self {
85        self.with_cookies(cookies)
86    }
87
88    fn with_cookies(&self, cookies: &[(String, String)]) -> Self {
89        let mut client = self.clone();
90        for (name, value) in cookies {
91            match client.cookies.iter_mut().find(|(kept, _)| kept == name) {
92                Some(existing) => existing.1 = value.clone(),
93                None => client.cookies.push((name.clone(), value.clone())),
94            }
95        }
96        client
97    }
98
99    /// Attach the CSRF cookie the form's `csrf_token` field must match.
100    pub fn csrf(&self, token: &str) -> Self {
101        self.cookie(tablo_core::csrf::COOKIE_NAME, token)
102    }
103
104    /// Signs `user` in to the panel `Panel::new(panel)` mounts, as a successful login does, and
105    /// attaches the session cookie; no password is hashed or checked.
106    ///
107    /// The session row goes into `db`, the router's database, keyed to [`PanelUser::user_id`]:
108    /// each request loads the user back through the panel's `Authenticator::find_by_id`, so a
109    /// custom authenticator works as the shipped one does.
110    ///
111    /// ```rust,no_run
112    /// # async fn sign_in(router: &topcoat::router::Router, mut db: toasty::Db) {
113    /// # use tablo_test::TestClient;
114    /// let admin = tablo_core::auth::create_admin(&mut db, "admin@example.com", "secret", "Admin")
115    ///     .await
116    ///     .expect("seed the admin");
117    /// let client = TestClient::new(router).sign_in(&db, "admin", &admin).await;
118    /// # let _ = client;
119    /// # }
120    /// ```
121    ///
122    /// # Panics
123    ///
124    /// When the session row cannot be written, e.g. the database does not register
125    /// `AuthSession`.
126    ///
127    /// [`PanelUser::user_id`]: tablo_core::PanelUser::user_id
128    pub async fn sign_in(&self, db: &toasty::Db, panel: &str, user: &impl PanelUser) -> Self {
129        let token = tablo_core::auth::mint_session(&mut db.clone(), panel, user)
130            .await
131            .expect("record the test session");
132        self.cookie(SESSION_COOKIE, &token)
133    }
134
135    /// Signs in through the login form of the panel `Panel::new(panel)` mounts, as a browser
136    /// does: loads the page, posts `email` and `password` with the page's CSRF token, and keeps
137    /// the cookies both responses set. Returns the client and the login response, which
138    /// redirects on success and re-renders the form on a refusal.
139    ///
140    /// `next` is the page to land on after signing in, `""` for the panel's home. Prefer
141    /// [`sign_in`](Self::sign_in) unless the test is about the login itself.
142    ///
143    /// # Panics
144    ///
145    /// When the login page renders no `csrf_token` input: the panel's authentication is off.
146    pub async fn log_in(
147        &self,
148        panel: &str,
149        email: &str,
150        password: &str,
151        next: &str,
152    ) -> (Self, http::Response<Body>) {
153        let url = format!("/{panel}/login");
154        let page = self.get(&url).await;
155        let cookies = response_cookies(&page);
156        let html = body_string(page).await;
157        let csrf = input_value(&html, "csrf_token")
158            .unwrap_or_else(|| panic!("the login page embeds a csrf_token input: {html}"));
159        let body = form_body(&[
160            ("email", email),
161            ("password", password),
162            ("next", next),
163            ("csrf_token", &csrf),
164        ]);
165        let client = self.cookies(&cookies);
166        let response = client.post_form(&url, body).await;
167        let session = response_cookies(&response);
168        (client.cookies(&session), response)
169    }
170
171    /// Scopes the request to another tenant, taking precedence over the
172    /// logged-in user's tenant.
173    pub fn tenant(&self, tenant: uuid::Uuid) -> Self {
174        let mut client = self.clone();
175        client.tenant = Some(tenant);
176        client
177    }
178
179    pub async fn get(&self, uri: &str) -> http::Response<Body> {
180        self.router
181            .handle(self.request(http::Method::GET, uri))
182            .await
183    }
184
185    pub async fn post_form(&self, uri: &str, body: String) -> http::Response<Body> {
186        self.post(
187            uri,
188            http::HeaderValue::from_static("application/x-www-form-urlencoded"),
189            body,
190        )
191        .await
192    }
193
194    /// Posts the urlencoded `body` as a panel form does: with a fresh CSRF token in both the
195    /// cookie and the `csrf_token` field.
196    pub async fn submit(&self, uri: &str, body: &str) -> http::Response<Body> {
197        let token = Self::fresh_csrf_token();
198        let field = form_body(&[("csrf_token", &token)]);
199        let body = if body.is_empty() {
200            field
201        } else {
202            format!("{body}&{field}")
203        };
204        self.csrf(&token).post_form(uri, body).await
205    }
206
207    /// Posts [`multipart_body`]'s `parts` as a panel form with a file input does: with a fresh
208    /// CSRF token in both the cookie and a `csrf_token` part.
209    pub async fn submit_multipart(
210        &self,
211        uri: &str,
212        parts: &[(&str, Option<&str>, &str)],
213    ) -> http::Response<Body> {
214        const BOUNDARY: &str = "tablo-test-boundary";
215        let token = Self::fresh_csrf_token();
216        let mut parts = parts.to_vec();
217        parts.push(("csrf_token", None, &token));
218        let body = multipart_body(BOUNDARY, &parts);
219        self.csrf(&token).post_multipart(uri, BOUNDARY, body).await
220    }
221
222    pub async fn post_multipart(
223        &self,
224        uri: &str,
225        boundary: &str,
226        body: String,
227    ) -> http::Response<Body> {
228        self.post(
229            uri,
230            format!("multipart/form-data; boundary={boundary}")
231                .parse()
232                .expect("multipart content type"),
233            body,
234        )
235        .await
236    }
237
238    async fn post(
239        &self,
240        uri: &str,
241        content_type: http::HeaderValue,
242        body: String,
243    ) -> http::Response<Body> {
244        let mut request = self.request(http::Method::POST, uri);
245        request.headers_mut().insert(CONTENT_TYPE, content_type);
246        *request.body_mut() = Body::from(body);
247        self.router.handle(request).await
248    }
249
250    fn fresh_csrf_token() -> String {
251        uuid::Uuid::new_v4().to_string()
252    }
253
254    fn request(&self, method: http::Method, uri: &str) -> http::Request<Body> {
255        let mut builder = http::Request::builder().method(method).uri(uri);
256        if let Some(jar) = cookie_header(
257            self.cookies
258                .iter()
259                .map(|(name, value)| (name.as_str(), value.as_str())),
260        ) {
261            builder = builder.header(COOKIE, jar);
262        }
263        let (mut parts, body) = builder.body(Body::empty()).unwrap().into_parts();
264        if let Some(tenant) = self.tenant {
265            parts.extensions.insert(tablo_core::Tenant(tenant));
266        }
267        http::Request::from_parts(parts, body)
268    }
269}