1mod 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
41pub fn mount(db: toasty::Db, panel: Panel) -> topcoat::Result<Router> {
48 mount_with_handle(db, panel).map(|(router, _)| router)
49}
50
51pub 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
66pub 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#[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 pub fn panel(&self, panel: &PanelHandle) -> Self {
132 let mut client = self.clone();
133 client.panel = Some(panel.clone());
134 client
135 }
136
137 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 pub fn cookie(&self, name: &str, value: &str) -> Self {
153 self.with_cookies(&[(name.to_string(), value.to_string())])
154 }
155
156 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 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 pub fn csrf(&self, token: &str) -> Self {
183 self.cookie(tablo_core::csrf::COOKIE_NAME, token)
184 }
185
186 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 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 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 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 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 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 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}