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