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