mod db;
mod protocol;
mod resource;
mod two_factor;
pub use db::{TEST_DATABASE_URL, migrated_db, test_db};
use http::header::{ACCEPT_LANGUAGE, CONTENT_TYPE, COOKIE};
pub use protocol::{
Bell, BellNotification, CellEdit, ChartData, ChartSeries, CreateOptionLink, DetailEntry,
DisabledAction, EmptyReason, EmptyTable, ErrorKind, FilterOption, Group, IgnoredFilter,
IgnoredReason, ImpersonationBanner, ImportError, ImportRefusal, LanguageChoice, NavGroup,
PageFilterBar, Picked, PickerColumn, RecordNavLink, RecordTableData, Row, RowActions,
SearchGroup, SearchResult, Section, StatTile, SummaryFigure, Tab, TabLink, WizardStep,
action_badge, action_label, action_tooltip, area_back_link, bell, body_bytes, body_string,
charts, column_picker, columns, confirms_first, cookie_header, create_option_links,
declined_message, detail_entries, dirty_form, disabled_actions, empty_table, external_sign_out,
field_error, field_error_message, filter_options, find_href_with, form_body, groups,
ignored_filters, impersonate_buttons, impersonation_banner, import_columns, import_errors,
import_refusal, inline_scripts, input_value, input_values, language_choices, multipart_body,
nav_badge, nav_groups, nav_links, notification, page_direction, page_filter_bar, page_language,
page_title, picked, posts_picking, record_navigation, record_tables, refreshes_to,
response_cookies, rich_text_values, robots, row_actions, row_keys, rows, search_box,
search_results, sections, selects_matching, session_cookie_value, set_cookie_header,
sortable_columns, stale_form, stats, summaries, tab_links, tabs, tenant_page_link, trail,
widgets, wizard_steps,
};
pub use resource::{FieldOf, Reply, ResourceClient, Submission};
use tablo_core::{
DeclarationError, MountError, Panel, PanelHandle, PanelUser, Resource, RouterBuilderPanelExt,
auth::session_cookie,
};
use topcoat::router::{Body, Router, RouterBuilderDiscoverExt};
pub use two_factor::{
authenticator_code, authenticator_code_at, now, recovery_codes, two_factor_secret,
two_factor_state,
};
pub fn mount(db: toasty::Db, panel: Panel) -> topcoat::Result<Router> {
mount_with_handle(db, panel).map(|(router, _)| router)
}
pub fn mount_with_handle(db: toasty::Db, panel: Panel) -> topcoat::Result<(Router, PanelHandle)> {
let prefix = panel.prefix().to_string();
let builder = Router::builder().discover().with(db).panel(panel)?;
let handle = builder
.panel_handle(&prefix)
.expect("the builder mounts the panel at its prefix");
Ok((builder.build(), handle))
}
pub fn refusal<T>(mounted: topcoat::Result<T>) -> Vec<DeclarationError> {
let Err(error) = mounted else {
panic!("the panel must not mount");
};
error
.downcast_ref::<MountError>()
.unwrap_or_else(|| panic!("a declaration mistake refuses the panel, got {error}"))
.errors()
.to_vec()
}
#[derive(Clone)]
pub struct TestClient<'a> {
router: &'a Router,
cookies: Vec<(String, String)>,
tenant: Option<tablo_core::TenantId>,
panel: Option<PanelHandle>,
language: Option<String>,
}
impl<'a> TestClient<'a> {
pub fn new(router: &'a Router) -> Self {
Self {
router,
cookies: Vec::new(),
tenant: None,
panel: None,
language: None,
}
}
pub fn panel(&self, panel: &PanelHandle) -> Self {
let mut client = self.clone();
client.panel = Some(panel.clone());
client
}
pub fn resource<R: Resource>(&self) -> ResourceClient<'_, 'a, R> {
let panel = self
.panel
.as_ref()
.expect("set the panel with `TestClient::panel` before `resource`");
ResourceClient::new(self, panel)
}
pub fn cookie(&self, name: &str, value: &str) -> Self {
self.with_cookies(&[(name.to_string(), value.to_string())])
}
pub fn cookies(&self, cookies: &[(String, String)]) -> Self {
self.with_cookies(cookies)
}
fn with_cookies(&self, cookies: &[(String, String)]) -> Self {
let mut client = self.clone();
for (name, value) in cookies {
match client.cookies.iter_mut().find(|(kept, _)| kept == name) {
Some(existing) => existing.1 = value.clone(),
None => client.cookies.push((name.clone(), value.clone())),
}
}
client
}
pub fn language(&self, languages: &str) -> Self {
let mut client = self.clone();
client.language = Some(languages.to_string());
client
}
pub fn csrf(&self, token: &str) -> Self {
self.cookie(tablo_core::csrf::COOKIE_NAME, token)
}
pub async fn sign_in(&self, db: &toasty::Db, panel: &str, user: &impl PanelUser) -> Self {
let token = tablo_core::auth::mint_session(&mut db.clone(), panel, user)
.await
.expect("record the test session");
self.cookie(&session_cookie(panel), &token)
}
pub async fn log_in(
&self,
panel: &str,
email: &str,
password: &str,
next: &str,
) -> (Self, http::Response<Body>) {
let url = format!("/{panel}/login");
let page = self.get(&url).await;
let cookies = response_cookies(&page);
let html = body_string(page).await;
let csrf = input_value(&html, "csrf_token")
.unwrap_or_else(|| panic!("the login page embeds a csrf_token input: {html}"));
let body = form_body(&[
("email", email),
("password", password),
("next", next),
("csrf_token", &csrf),
]);
let client = self.cookies(&cookies);
let response = client.post_form(&url, body).await;
let session = response_cookies(&response);
(client.cookies(&session), response)
}
pub fn tenant(&self, tenant: tablo_core::TenantId) -> Self {
let mut client = self.clone();
client.tenant = Some(tenant);
client
}
pub async fn get(&self, uri: &str) -> http::Response<Body> {
self.router
.handle(self.request(http::Method::GET, uri))
.await
}
pub async fn head(&self, uri: &str) -> http::Response<Body> {
self.router
.handle(self.request(http::Method::HEAD, uri))
.await
}
pub async fn post_form(&self, uri: &str, body: String) -> http::Response<Body> {
self.post(
uri,
http::HeaderValue::from_static("application/x-www-form-urlencoded"),
body,
)
.await
}
pub async fn submit(&self, uri: &str, body: &str) -> http::Response<Body> {
let token = self.csrf_token();
let field = form_body(&[("csrf_token", &token)]);
let body = if body.is_empty() {
field
} else {
format!("{body}&{field}")
};
self.csrf(&token).post_form(uri, body).await
}
pub async fn submit_multipart(
&self,
uri: &str,
parts: &[(&str, Option<&str>, &str)],
) -> http::Response<Body> {
const BOUNDARY: &str = "tablo-test-boundary";
let token = self.csrf_token();
let mut parts = parts.to_vec();
parts.push(("csrf_token", None, &token));
let body = multipart_body(BOUNDARY, &parts);
self.csrf(&token).post_multipart(uri, BOUNDARY, body).await
}
pub async fn post_multipart(
&self,
uri: &str,
boundary: &str,
body: String,
) -> http::Response<Body> {
self.post(
uri,
format!("multipart/form-data; boundary={boundary}")
.parse()
.expect("multipart content type"),
body,
)
.await
}
pub async fn call_procedure(&self, path: &str, args: &str) -> http::Response<Body> {
self.post(
path,
http::HeaderValue::from_static("application/json"),
args.to_string(),
)
.await
}
async fn post(
&self,
uri: &str,
content_type: http::HeaderValue,
body: String,
) -> http::Response<Body> {
let mut request = self.request(http::Method::POST, uri);
request.headers_mut().insert(CONTENT_TYPE, content_type);
*request.body_mut() = Body::from(body);
self.router.handle(request).await
}
fn csrf_token(&self) -> String {
self.cookies
.iter()
.find(|(name, _)| name == tablo_core::csrf::COOKIE_NAME)
.map_or_else(
|| uuid::Uuid::new_v4().to_string(),
|(_, token)| token.clone(),
)
}
fn request(&self, method: http::Method, uri: &str) -> http::Request<Body> {
let mut builder = http::Request::builder().method(method).uri(uri);
if let Some(jar) = cookie_header(
self.cookies
.iter()
.map(|(name, value)| (name.as_str(), value.as_str())),
) {
builder = builder.header(COOKIE, jar);
}
if let Some(languages) = &self.language {
builder = builder.header(ACCEPT_LANGUAGE, languages);
}
let (mut parts, body) = builder.body(Body::empty()).unwrap().into_parts();
if let Some(tenant) = self.tenant {
parts.extensions.insert(tablo_core::ClaimedTenant(tenant));
}
http::Request::from_parts(parts, body)
}
}