1mod 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
31pub fn mount(db: toasty::Db, panel: Panel) -> topcoat::Result<Router> {
38 mount_with_handle(db, panel).map(|(router, _)| router)
39}
40
41pub 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
56pub 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#[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 pub fn panel(&self, panel: &PanelHandle) -> Self {
122 let mut client = self.clone();
123 client.panel = Some(panel.clone());
124 client
125 }
126
127 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 pub fn cookie(&self, name: &str, value: &str) -> Self {
143 self.with_cookies(&[(name.to_string(), value.to_string())])
144 }
145
146 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 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 pub fn csrf(&self, token: &str) -> Self {
173 self.cookie(tablo_core::csrf::COOKIE_NAME, token)
174 }
175
176 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 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 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 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 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 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 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}