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 db;
5mod protocol;
6mod resource;
7mod two_factor;
8
9pub use db::{TEST_DATABASE_URL, test_db};
10use http::header::{ACCEPT_LANGUAGE, CONTENT_TYPE, COOKIE};
11pub use protocol::{
12    Bell, BellNotification, EmptyReason, EmptyTable, ErrorKind, FilterOption, Group, IgnoredFilter,
13    IgnoredReason, ImportError, ImportRefusal, PickerColumn, Row, RowActions, SESSION_COOKIE,
14    SearchGroup, SearchResult, Tab, bell, body_bytes, body_string, column_picker, columns,
15    confirms_first, cookie_header, empty_table, field_error, field_error_message, filter_options,
16    find_href_with, form_body, groups, ignored_filters, import_columns, import_errors,
17    import_refusal, input_value, multipart_body, nav_badge, notification, page_direction,
18    page_language, response_cookies, rich_text_values, row_actions, row_keys, rows, search_box,
19    search_results, session_cookie_value, set_cookie_header, stale_form, tabs,
20};
21pub use resource::{FieldOf, Reply, ResourceClient, Submission};
22use tablo_core::{
23    DeclarationError, MountError, Panel, PanelHandle, PanelUser, Resource, RouterBuilderPanelExt,
24};
25use topcoat::router::{Body, Router, RouterBuilderDiscoverExt};
26pub use two_factor::{
27    authenticator_code, authenticator_code_at, now, recovery_codes, two_factor_secret,
28    two_factor_state,
29};
30
31/// `panel` mounted on a router holding `db`, beside the app's own discovered pages and routes:
32/// the router an app builds, without its asset bundle.
33///
34/// # Errors
35///
36/// The [`MountError`] the panel refuses to mount with, which [`refusal`] lists.
37pub fn mount(db: toasty::Db, panel: Panel) -> topcoat::Result<Router> {
38    mount_with_handle(db, panel).map(|(router, _)| router)
39}
40
41/// [`mount`]'s router and the mounted panel's handle, which [`TestClient::panel`] resolves
42/// [`TestClient::resource`]'s URLs in.
43///
44/// # Errors
45///
46/// The [`MountError`] the panel refuses to mount with.
47pub fn mount_with_handle(db: toasty::Db, panel: Panel) -> topcoat::Result<(Router, PanelHandle)> {
48    let prefix = panel.prefix().to_string();
49    let builder = Router::builder().discover().app_context(db).panel(panel)?;
50    let handle = builder
51        .panel_handle(&prefix)
52        .expect("the builder mounts the panel at its prefix");
53    Ok((builder.build(), handle))
54}
55
56/// The declaration mistakes a panel refused to mount with, for a test to match on their
57/// [`kind`](DeclarationError::kind).
58///
59/// ```rust,no_run
60/// # fn refused(db: toasty::Db, panel: tablo_core::Panel) {
61/// use tablo_test::{mount, refusal};
62///
63/// let mistakes = refusal(mount(db, panel));
64/// assert!(matches!(
65///     mistakes[0].kind,
66///     tablo_core::DeclarationErrorKind::MissingAuthModels { .. }
67/// ));
68/// # }
69/// ```
70///
71/// # Panics
72///
73/// When `mounted` succeeded, or failed with an error that is not a [`MountError`].
74pub fn refusal<T>(mounted: topcoat::Result<T>) -> Vec<DeclarationError> {
75    let Err(error) = mounted else {
76        panic!("the panel must not mount");
77    };
78    error
79        .downcast_ref::<MountError>()
80        .unwrap_or_else(|| panic!("a declaration mistake refuses the panel, got {error}"))
81        .errors()
82        .to_vec()
83}
84
85/// Sends requests carrying cookies and tenant.
86#[derive(Clone)]
87pub struct TestClient<'a> {
88    router: &'a Router,
89    cookies: Vec<(String, String)>,
90    tenant: Option<uuid::Uuid>,
91    panel: Option<PanelHandle>,
92    language: Option<String>,
93}
94
95impl<'a> TestClient<'a> {
96    pub fn new(router: &'a Router) -> Self {
97        Self {
98            router,
99            cookies: Vec::new(),
100            tenant: None,
101            panel: None,
102            language: None,
103        }
104    }
105
106    /// Resolves [`resource`](Self::resource)'s URLs in `panel`, the handle the router's builder
107    /// gives through [`panel_handle`](RouterBuilderPanelExt::panel_handle).
108    ///
109    /// ```rust,no_run
110    /// # use tablo_core::{Panel, RouterBuilderPanelExt};
111    /// # use topcoat::router::{Router, RouterBuilderDiscoverExt};
112    /// # fn build(db: toasty::Db, panel: Panel) -> topcoat::Result<()> {
113    /// let builder = Router::builder().discover().app_context(db).panel(panel)?;
114    /// let admin = builder.panel_handle("admin").expect("mounted above");
115    /// let router = builder.build();
116    /// let client = tablo_test::TestClient::new(&router).panel(&admin);
117    /// # let _ = client;
118    /// # Ok(())
119    /// # }
120    /// ```
121    pub fn panel(&self, panel: &PanelHandle) -> Self {
122        let mut client = self.clone();
123        client.panel = Some(panel.clone());
124        client
125    }
126
127    /// Typed requests to `R`, whose URLs and form keys come from the panel's own declaration.
128    ///
129    /// # Panics
130    ///
131    /// When no [`panel`](Self::panel) is set.
132    pub fn resource<R: Resource>(&self) -> ResourceClient<'_, 'a, R> {
133        let panel = self
134            .panel
135            .as_ref()
136            .expect("set the panel with `TestClient::panel` before `resource`");
137        ResourceClient::new(self, panel)
138    }
139
140    /// Attaches a cookie to every request; a later value for the same name
141    /// replaces the earlier one.
142    pub fn cookie(&self, name: &str, value: &str) -> Self {
143        self.with_cookies(&[(name.to_string(), value.to_string())])
144    }
145
146    /// Attaches every `(name, value)` pair, replacing earlier values for the
147    /// same name.
148    pub fn cookies(&self, cookies: &[(String, String)]) -> Self {
149        self.with_cookies(cookies)
150    }
151
152    fn with_cookies(&self, cookies: &[(String, String)]) -> Self {
153        let mut client = self.clone();
154        for (name, value) in cookies {
155            match client.cookies.iter_mut().find(|(kept, _)| kept == name) {
156                Some(existing) => existing.1 = value.clone(),
157                None => client.cookies.push((name.clone(), value.clone())),
158            }
159        }
160        client
161    }
162
163    /// Sends `Accept-Language: {languages}` with every request, as a browser preferring them does:
164    /// `"fr-CA, en;q=0.5"`.
165    pub fn language(&self, languages: &str) -> Self {
166        let mut client = self.clone();
167        client.language = Some(languages.to_string());
168        client
169    }
170
171    /// Attach the CSRF cookie the form's `csrf_token` field must match.
172    pub fn csrf(&self, token: &str) -> Self {
173        self.cookie(tablo_core::csrf::COOKIE_NAME, token)
174    }
175
176    /// Signs `user` in to the panel `Panel::new(panel)` mounts, as a successful login does, and
177    /// attaches the session cookie; no password is hashed or checked.
178    ///
179    /// The session row goes into `db`, the router's database, keyed to [`PanelUser::user_id`]:
180    /// each request loads the user back through the panel's `Authenticator::find_by_id`, so a
181    /// custom authenticator works as the shipped one does.
182    ///
183    /// ```rust,no_run
184    /// # async fn sign_in(router: &topcoat::router::Router, mut db: toasty::Db) {
185    /// # use tablo_test::TestClient;
186    /// let admin = tablo_core::auth::create_admin(&mut db, "admin@example.com", "secret", "Admin")
187    ///     .await
188    ///     .expect("seed the admin");
189    /// let client = TestClient::new(router).sign_in(&db, "admin", &admin).await;
190    /// # let _ = client;
191    /// # }
192    /// ```
193    ///
194    /// # Panics
195    ///
196    /// When the session row cannot be written, e.g. the database does not register
197    /// `AuthSession`.
198    ///
199    /// [`PanelUser::user_id`]: tablo_core::PanelUser::user_id
200    pub async fn sign_in(&self, db: &toasty::Db, panel: &str, user: &impl PanelUser) -> Self {
201        let token = tablo_core::auth::mint_session(&mut db.clone(), panel, user)
202            .await
203            .expect("record the test session");
204        self.cookie(SESSION_COOKIE, &token)
205    }
206
207    /// Signs in through the login form of the panel `Panel::new(panel)` mounts, as a browser
208    /// does: loads the page, posts `email` and `password` with the page's CSRF token, and keeps
209    /// the cookies both responses set. Returns the client and the login response, which
210    /// redirects on success and re-renders the form on a refusal.
211    ///
212    /// `next` is the page to land on after signing in, `""` for the panel's home. Prefer
213    /// [`sign_in`](Self::sign_in) unless the test is about the login itself.
214    ///
215    /// # Panics
216    ///
217    /// When the login page renders no `csrf_token` input: the panel's authentication is off.
218    pub async fn log_in(
219        &self,
220        panel: &str,
221        email: &str,
222        password: &str,
223        next: &str,
224    ) -> (Self, http::Response<Body>) {
225        let url = format!("/{panel}/login");
226        let page = self.get(&url).await;
227        let cookies = response_cookies(&page);
228        let html = body_string(page).await;
229        let csrf = input_value(&html, "csrf_token")
230            .unwrap_or_else(|| panic!("the login page embeds a csrf_token input: {html}"));
231        let body = form_body(&[
232            ("email", email),
233            ("password", password),
234            ("next", next),
235            ("csrf_token", &csrf),
236        ]);
237        let client = self.cookies(&cookies);
238        let response = client.post_form(&url, body).await;
239        let session = response_cookies(&response);
240        (client.cookies(&session), response)
241    }
242
243    /// Scopes the request to another tenant, taking precedence over the
244    /// logged-in user's tenant.
245    pub fn tenant(&self, tenant: uuid::Uuid) -> Self {
246        let mut client = self.clone();
247        client.tenant = Some(tenant);
248        client
249    }
250
251    pub async fn get(&self, uri: &str) -> http::Response<Body> {
252        self.router
253            .handle(self.request(http::Method::GET, uri))
254            .await
255    }
256
257    pub async fn post_form(&self, uri: &str, body: String) -> http::Response<Body> {
258        self.post(
259            uri,
260            http::HeaderValue::from_static("application/x-www-form-urlencoded"),
261            body,
262        )
263        .await
264    }
265
266    /// Posts the urlencoded `body` as a panel form does: with the CSRF token in both the cookie
267    /// and the `csrf_token` field, the one [`csrf`](Self::csrf) attached, else a fresh one.
268    pub async fn submit(&self, uri: &str, body: &str) -> http::Response<Body> {
269        let token = self.csrf_token();
270        let field = form_body(&[("csrf_token", &token)]);
271        let body = if body.is_empty() {
272            field
273        } else {
274            format!("{body}&{field}")
275        };
276        self.csrf(&token).post_form(uri, body).await
277    }
278
279    /// Posts [`multipart_body`]'s `parts` as a panel form with a file input does: with the CSRF
280    /// token in both the cookie and a `csrf_token` part, as [`submit`](Self::submit) picks it.
281    pub async fn submit_multipart(
282        &self,
283        uri: &str,
284        parts: &[(&str, Option<&str>, &str)],
285    ) -> http::Response<Body> {
286        const BOUNDARY: &str = "tablo-test-boundary";
287        let token = self.csrf_token();
288        let mut parts = parts.to_vec();
289        parts.push(("csrf_token", None, &token));
290        let body = multipart_body(BOUNDARY, &parts);
291        self.csrf(&token).post_multipart(uri, BOUNDARY, body).await
292    }
293
294    pub async fn post_multipart(
295        &self,
296        uri: &str,
297        boundary: &str,
298        body: String,
299    ) -> http::Response<Body> {
300        self.post(
301            uri,
302            format!("multipart/form-data; boundary={boundary}")
303                .parse()
304                .expect("multipart content type"),
305            body,
306        )
307        .await
308    }
309
310    /// Calls the runtime procedure served at `path` with `args`, the JSON array of its
311    /// arguments, as the browser's runtime does: a JSON `POST` that answers with the JSON of its
312    /// result.
313    ///
314    /// ```rust,no_run
315    /// # async fn read(client: &tablo_test::TestClient<'_>, id: &str) {
316    /// let response = client
317    ///     .call_procedure(
318    ///         "/_topcoat/runtime/procedures/tablo-notifications/mark",
319    ///         &format!(r#"["{id}", true]"#),
320    ///     )
321    ///     .await;
322    /// assert_eq!(response.status(), 200);
323    /// # }
324    /// ```
325    pub async fn call_procedure(&self, path: &str, args: &str) -> http::Response<Body> {
326        self.post(
327            path,
328            http::HeaderValue::from_static("application/json"),
329            args.to_string(),
330        )
331        .await
332    }
333
334    async fn post(
335        &self,
336        uri: &str,
337        content_type: http::HeaderValue,
338        body: String,
339    ) -> http::Response<Body> {
340        let mut request = self.request(http::Method::POST, uri);
341        request.headers_mut().insert(CONTENT_TYPE, content_type);
342        *request.body_mut() = Body::from(body);
343        self.router.handle(request).await
344    }
345
346    /// The CSRF cookie the client carries, as a browser keeps it across posts, else a fresh one.
347    fn csrf_token(&self) -> String {
348        self.cookies
349            .iter()
350            .find(|(name, _)| name == tablo_core::csrf::COOKIE_NAME)
351            .map_or_else(
352                || uuid::Uuid::new_v4().to_string(),
353                |(_, token)| token.clone(),
354            )
355    }
356
357    fn request(&self, method: http::Method, uri: &str) -> http::Request<Body> {
358        let mut builder = http::Request::builder().method(method).uri(uri);
359        if let Some(jar) = cookie_header(
360            self.cookies
361                .iter()
362                .map(|(name, value)| (name.as_str(), value.as_str())),
363        ) {
364            builder = builder.header(COOKIE, jar);
365        }
366        if let Some(languages) = &self.language {
367            builder = builder.header(ACCEPT_LANGUAGE, languages);
368        }
369        let (mut parts, body) = builder.body(Body::empty()).unwrap().into_parts();
370        if let Some(tenant) = self.tenant {
371            parts.extensions.insert(tablo_core::Tenant(tenant));
372        }
373        http::Request::from_parts(parts, body)
374    }
375}