1mod 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
43pub fn mount(db: toasty::Db, panel: Panel) -> topcoat::Result<Router> {
50 mount_with_handle(db, panel).map(|(router, _)| router)
51}
52
53pub 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
68pub 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#[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 pub fn panel(&self, panel: &PanelHandle) -> Self {
134 let mut client = self.clone();
135 client.panel = Some(panel.clone());
136 client
137 }
138
139 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 pub fn cookie(&self, name: &str, value: &str) -> Self {
155 self.with_cookies(&[(name.to_string(), value.to_string())])
156 }
157
158 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 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 pub fn csrf(&self, token: &str) -> Self {
185 self.cookie(tablo_core::csrf::COOKIE_NAME, token)
186 }
187
188 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 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 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 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 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 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 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}