diff --git a/crates/cli/src/commands/templates.rs b/crates/cli/src/commands/templates.rs index 6665f7f23..8f1b0dd4e 100644 --- a/crates/cli/src/commands/templates.rs +++ b/crates/cli/src/commands/templates.rs @@ -4,8 +4,10 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use std::process::ExitCode; +use std::{fmt::Write, process::ExitCode}; +use anyhow::{Context as _, bail}; +use camino::Utf8PathBuf; use clap::Parser; use figment::Figment; use mas_config::{ @@ -27,14 +29,19 @@ pub(super) struct Options { #[derive(Parser, Debug)] enum Subcommand { /// Check that the templates specified in the config are valid - Check, + Check { + /// If set, templates will be rendered to this directory. + /// The directory must either not exist or be empty. + #[arg(long = "out-dir")] + out_dir: Option, + }, } impl Options { pub async fn run(self, figment: &Figment) -> anyhow::Result { use Subcommand as SC; match self.subcommand { - SC::Check => { + SC::Check { out_dir } => { let _span = info_span!("cli.templates.check").entered(); let template_config = TemplatesConfig::extract_or_default(figment) @@ -68,12 +75,51 @@ impl Options { let templates = templates_from_config( &template_config, &site_config, - &url_builder, - // Use strict mode in template checks + &url_builder, // Use strict mode in template checks true, ) .await?; - templates.check_render(clock.now(), &mut rng)?; + let all_renders = templates.check_render(clock.now(), &mut rng)?; + + if let Some(out_dir) = out_dir { + // Save renders to disk. + if out_dir.exists() { + let mut read_dir = + tokio::fs::read_dir(&out_dir).await.with_context(|| { + format!("could not read {out_dir} to check it's empty") + })?; + if read_dir.next_entry().await?.is_some() { + bail!("Render directory {out_dir} is not empty, refusing to write."); + } + } else { + tokio::fs::create_dir(&out_dir) + .await + .with_context(|| format!("could not create {out_dir}"))?; + } + + for ((template, sample_identifier), template_render) in &all_renders { + let (template_filename_base, template_ext) = + template.rsplit_once('.').unwrap_or((template, "txt")); + let template_filename_base = template_filename_base.replace('/', "_"); + + // Make a string like `-index=0-browser-session=0-locale=fr` + let sample_suffix = { + let mut s = String::new(); + for (k, v) in &sample_identifier.components { + write!(s, "-{k}={v}")?; + } + s + }; + + let render_path = out_dir.join(format!( + "{template_filename_base}{sample_suffix}.{template_ext}" + )); + + tokio::fs::write(&render_path, template_render.as_bytes()) + .await + .with_context(|| format!("could not write render to {render_path}"))?; + } + } Ok(ExitCode::SUCCESS) } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 0a04677eb..597683a03 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -12,6 +12,7 @@ mod ext; mod features; use std::{ + collections::BTreeMap, fmt::Formatter, net::{IpAddr, Ipv4Addr}, }; @@ -105,21 +106,53 @@ pub trait TemplateContext: Serialize { /// /// This is then used to check for template validity in unit tests and in /// the CLI (`cargo run -- templates check`) - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized; } +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct SampleIdentifier { + pub components: Vec<(&'static str, String)>, +} + +impl SampleIdentifier { + pub fn from_index(index: usize) -> Self { + Self { + components: Vec::default(), + } + .with_appended("index", format!("{index}")) + } + + pub fn with_appended(&self, kind: &'static str, locale: String) -> Self { + let mut new = self.clone(); + new.components.push((kind, locale)); + new + } +} + +pub(crate) fn sample_list(samples: Vec) -> BTreeMap { + samples + .into_iter() + .enumerate() + .map(|(index, sample)| (SampleIdentifier::from_index(index), sample)) + .collect() +} + impl TemplateContext for () { fn sample( _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - Vec::new() + BTreeMap::new() } } @@ -148,7 +181,11 @@ impl std::ops::Deref for WithLanguage { } impl TemplateContext for WithLanguage { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -157,9 +194,14 @@ impl TemplateContext for WithLanguage { .flat_map(|locale| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithLanguage { - lang: locale.to_string(), - inner, + .map(|(sample_id, sample)| { + ( + sample_id.with_appended("locale", locale.to_string()), + WithLanguage { + lang: locale.to_string(), + inner: sample, + }, + ) }) }) .collect() @@ -176,15 +218,24 @@ pub struct WithCsrf { } impl TemplateContext for WithCsrf { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { T::sample(now, rng, locales) .into_iter() - .map(|inner| WithCsrf { - csrf_token: "fake_csrf_token".into(), - inner, + .map(|(k, inner)| { + ( + k, + WithCsrf { + csrf_token: "fake_csrf_token".into(), + inner, + }, + ) }) .collect() } @@ -200,18 +251,28 @@ pub struct WithSession { } impl TemplateContext for WithSession { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { BrowserSession::samples(now, rng) .into_iter() - .flat_map(|session| { + .enumerate() + .flat_map(|(session_index, session)| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithSession { - current_session: session.clone(), - inner, + .map(move |(k, inner)| { + ( + k.with_appended("browser-session", session_index.to_string()), + WithSession { + current_session: session.clone(), + inner, + }, + ) }) }) .collect() @@ -228,7 +289,11 @@ pub struct WithOptionalSession { } impl TemplateContext for WithOptionalSession { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -236,12 +301,22 @@ impl TemplateContext for WithOptionalSession { .into_iter() .map(Some) // Wrap all samples in an Option .chain(std::iter::once(None)) // Add the "None" option - .flat_map(|session| { + .enumerate() + .flat_map(|(session_index, session)| { T::sample(now, rng, locales) .into_iter() - .map(move |inner| WithOptionalSession { - current_session: session.clone(), - inner, + .map(move |(k, inner)| { + ( + if session.is_some() { + k.with_appended("browser-session", session_index.to_string()) + } else { + k + }, + WithOptionalSession { + current_session: session.clone(), + inner, + }, + ) }) }) .collect() @@ -269,11 +344,11 @@ impl TemplateContext for EmptyContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![EmptyContext] + sample_list(vec![EmptyContext]) } } @@ -297,15 +372,15 @@ impl TemplateContext for IndexContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { discovery_url: "https://example.com/.well-known/openid-configuration" .parse() .unwrap(), - }] + }]) } } @@ -343,12 +418,12 @@ impl TemplateContext for AppContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); - vec![Self::from_url_builder(&url_builder)] + sample_list(vec![Self::from_url_builder(&url_builder)]) } } @@ -376,12 +451,12 @@ impl TemplateContext for ApiDocContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); - vec![Self::from_url_builder(&url_builder)] + sample_list(vec![Self::from_url_builder(&url_builder)]) } } @@ -468,12 +543,12 @@ impl TemplateContext for LoginContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { // TODO: samples with errors - vec![ + sample_list(vec![ LoginContext { form: FormState::default(), next: None, @@ -503,7 +578,7 @@ impl TemplateContext for LoginContext { next: None, providers: Vec::new(), }, - ] + ]) } } @@ -576,14 +651,14 @@ impl TemplateContext for RegisterContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![RegisterContext { + sample_list(vec![RegisterContext { providers: Vec::new(), next: None, - }] + }]) } } @@ -619,15 +694,15 @@ impl TemplateContext for PasswordRegisterContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { // TODO: samples with errors - vec![PasswordRegisterContext { + sample_list(vec![PasswordRegisterContext { form: FormState::default(), next: None, - }] + }]) } } @@ -657,24 +732,30 @@ pub struct ConsentContext { } impl TemplateContext for ConsentContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) - .into_iter() - .map(|client| { - let mut grant = AuthorizationGrant::sample(now, rng); - let action = PostAuthAction::continue_grant(grant.id); - // XXX - grant.client_id = client.id; - Self { - grant, - client, - action, - } - }) - .collect() + sample_list( + Client::samples(now, rng) + .into_iter() + .map(|client| { + let mut grant = AuthorizationGrant::sample(now, rng); + let action = PostAuthAction::continue_grant(grant.id); + // XXX + grant.client_id = client.id; + Self { + grant, + client, + action, + } + }) + .collect(), + ) } } @@ -709,38 +790,44 @@ pub struct PolicyViolationContext { } impl TemplateContext for PolicyViolationContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) - .into_iter() - .flat_map(|client| { - let mut grant = AuthorizationGrant::sample(now, rng); - // XXX - grant.client_id = client.id; - - let authorization_grant = - PolicyViolationContext::for_authorization_grant(grant, client.clone()); - let device_code_grant = PolicyViolationContext::for_device_code_grant( - DeviceCodeGrant { - id: Ulid::from_datetime_with_source(now.into(), rng), - state: mas_data_model::DeviceCodeGrantState::Pending, - client_id: client.id, - scope: [OPENID].into_iter().collect(), - user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), - device_code: Alphanumeric.sample_string(rng, 32), - created_at: now - Duration::try_minutes(5).unwrap(), - expires_at: now + Duration::try_minutes(25).unwrap(), - ip_address: None, - user_agent: None, - }, - client, - ); + sample_list( + Client::samples(now, rng) + .into_iter() + .flat_map(|client| { + let mut grant = AuthorizationGrant::sample(now, rng); + // XXX + grant.client_id = client.id; + + let authorization_grant = + PolicyViolationContext::for_authorization_grant(grant, client.clone()); + let device_code_grant = PolicyViolationContext::for_device_code_grant( + DeviceCodeGrant { + id: Ulid::from_datetime_with_source(now.into(), rng), + state: mas_data_model::DeviceCodeGrantState::Pending, + client_id: client.id, + scope: [OPENID].into_iter().collect(), + user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), + device_code: Alphanumeric.sample_string(rng, 32), + created_at: now - Duration::try_minutes(5).unwrap(), + expires_at: now + Duration::try_minutes(25).unwrap(), + ip_address: None, + user_agent: None, + }, + client, + ); - [authorization_grant, device_code_grant] - }) - .collect() + [authorization_grant, device_code_grant] + }) + .collect(), + ) } } @@ -778,18 +865,22 @@ pub struct CompatSsoContext { } impl TemplateContext for CompatSsoContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let id = Ulid::from_datetime_with_source(now.into(), rng); - vec![CompatSsoContext::new(CompatSsoLogin { + sample_list(vec![CompatSsoContext::new(CompatSsoLogin { id, redirect_uri: Url::parse("https://app.element.io/").unwrap(), login_token: "abcdefghijklmnopqrstuvwxyz012345".into(), created_at: now, state: CompatSsoLoginState::Pending, - })] + })]) } } @@ -836,11 +927,15 @@ impl EmailRecoveryContext { } impl TemplateContext for EmailRecoveryContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng).into_iter().map(|user| { + sample_list(User::samples(now, rng).into_iter().map(|user| { let session = UserRecoverySession { id: Ulid::from_datetime_with_source(now.into(), rng), email: "hello@example.com".to_owned(), @@ -854,7 +949,7 @@ impl TemplateContext for EmailRecoveryContext { let link = "https://example.com/recovery/complete?ticket=abcdefghijklmnopqrstuvwxyz0123456789".parse().unwrap(); Self::new(user, session, link) - }).collect() + }).collect()) } } @@ -897,28 +992,37 @@ impl EmailVerificationContext { } impl TemplateContext for EmailVerificationContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - BrowserSession::samples(now, rng) - .into_iter() - .map(|browser_session| { - let authentication_code = UserEmailAuthenticationCode { - id: Ulid::from_datetime_with_source(now.into(), rng), - user_email_authentication_id: Ulid::from_datetime_with_source(now.into(), rng), - code: "123456".to_owned(), - created_at: now - Duration::try_minutes(5).unwrap(), - expires_at: now + Duration::try_minutes(25).unwrap(), - }; + sample_list( + BrowserSession::samples(now, rng) + .into_iter() + .map(|browser_session| { + let authentication_code = UserEmailAuthenticationCode { + id: Ulid::from_datetime_with_source(now.into(), rng), + user_email_authentication_id: Ulid::from_datetime_with_source( + now.into(), + rng, + ), + code: "123456".to_owned(), + created_at: now - Duration::try_minutes(5).unwrap(), + expires_at: now + Duration::try_minutes(25).unwrap(), + }; - Self { - browser_session: Some(browser_session), - user_registration: None, - authentication_code, - } - }) - .collect() + Self { + browser_session: Some(browser_session), + user_registration: None, + authentication_code, + } + }) + .collect(), + ) } } @@ -963,7 +1067,11 @@ impl RegisterStepsVerifyEmailContext { } impl TemplateContext for RegisterStepsVerifyEmailContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -976,10 +1084,10 @@ impl TemplateContext for RegisterStepsVerifyEmailContext { completed_at: None, }; - vec![Self { + sample_list(vec![Self { form: FormState::default(), authentication, - }] + }]) } } @@ -1003,13 +1111,13 @@ impl TemplateContext for RegisterStepsEmailInUseContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { let email = "hello@example.com".to_owned(); let action = PostAuthAction::continue_grant(Ulid::nil()); - vec![Self::new(email, Some(action))] + sample_list(vec![Self::new(email, Some(action))]) } } @@ -1058,13 +1166,13 @@ impl TemplateContext for RegisterStepsDisplayNameContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { form: FormState::default(), - }] + }]) } } @@ -1113,13 +1221,13 @@ impl TemplateContext for RegisterStepsRegistrationTokenContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![Self { + sample_list(vec![Self { form: FormState::default(), - }] + }]) } } @@ -1164,11 +1272,11 @@ impl TemplateContext for RecoveryStartContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(), Self::new().with_form_state( FormState::default() @@ -1178,7 +1286,7 @@ impl TemplateContext for RecoveryStartContext { FormState::default() .with_error_on_field(RecoveryStartFormField::Email, FieldError::Invalid), ), - ] + ]) } } @@ -1202,7 +1310,11 @@ impl RecoveryProgressContext { } impl TemplateContext for RecoveryProgressContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -1216,7 +1328,7 @@ impl TemplateContext for RecoveryProgressContext { consumed_at: None, }; - vec![ + sample_list(vec![ Self { session: session.clone(), resend_failed_due_to_rate_limit: false, @@ -1225,7 +1337,7 @@ impl TemplateContext for RecoveryProgressContext { session, resend_failed_due_to_rate_limit: true, }, - ] + ]) } } @@ -1244,7 +1356,11 @@ impl RecoveryExpiredContext { } impl TemplateContext for RecoveryExpiredContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { @@ -1258,10 +1374,9 @@ impl TemplateContext for RecoveryExpiredContext { consumed_at: None, }; - vec![Self { session }] + sample_list(vec![Self { session }]) } } - /// Fields of the account recovery finish form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -1305,30 +1420,36 @@ impl RecoveryFinishContext { } impl TemplateContext for RecoveryFinishContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .flat_map(|user| { - vec![ - Self::new(user.clone()), - Self::new(user.clone()).with_form_state( - FormState::default().with_error_on_field( - RecoveryFinishFormField::NewPassword, - FieldError::Invalid, + sample_list( + User::samples(now, rng) + .into_iter() + .flat_map(|user| { + vec![ + Self::new(user.clone()), + Self::new(user.clone()).with_form_state( + FormState::default().with_error_on_field( + RecoveryFinishFormField::NewPassword, + FieldError::Invalid, + ), ), - ), - Self::new(user.clone()).with_form_state( - FormState::default().with_error_on_field( - RecoveryFinishFormField::NewPasswordConfirm, - FieldError::Invalid, + Self::new(user.clone()).with_form_state( + FormState::default().with_error_on_field( + RecoveryFinishFormField::NewPasswordConfirm, + FieldError::Invalid, + ), ), - ), - ] - }) - .collect() + ] + }) + .collect(), + ) } } @@ -1348,14 +1469,20 @@ impl UpstreamExistingLinkContext { } impl TemplateContext for UpstreamExistingLinkContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .map(|linked_user| Self { linked_user }) - .collect() + sample_list( + User::samples(now, rng) + .into_iter() + .map(|linked_user| Self { linked_user }) + .collect(), + ) } } @@ -1380,12 +1507,16 @@ impl UpstreamSuggestLink { } impl TemplateContext for UpstreamSuggestLink { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let id = Ulid::from_datetime_with_source(now.into(), rng); - vec![Self::for_link_id(id)] + sample_list(vec![Self::for_link_id(id)]) } } @@ -1505,11 +1636,15 @@ impl UpstreamRegister { } impl TemplateContext for UpstreamRegister { - fn sample(now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - vec![Self::new( + sample_list(vec![Self::new( UpstreamOAuthLink { id: Ulid::nil(), provider_id: Ulid::nil(), @@ -1545,7 +1680,7 @@ impl TemplateContext for UpstreamRegister { disabled_at: None, on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::DoNothing, }, - )] + )]) } } @@ -1591,17 +1726,17 @@ impl TemplateContext for DeviceLinkContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(), Self::new().with_form_state( FormState::default() .with_error_on_field(DeviceLinkFormField::Code, FieldError::Required), ), - ] + ]) } } @@ -1621,13 +1756,17 @@ impl DeviceConsentContext { } impl TemplateContext for DeviceConsentContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) + sample_list(Client::samples(now, rng) .into_iter() - .map(|client| { + .map(|client| { let grant = DeviceCodeGrant { id: Ulid::from_datetime_with_source(now.into(), rng), state: mas_data_model::DeviceCodeGrantState::Pending, @@ -1642,7 +1781,7 @@ impl TemplateContext for DeviceConsentContext { }; Self { grant, client } }) - .collect() + .collect()) } } @@ -1662,14 +1801,20 @@ impl AccountInactiveContext { } impl TemplateContext for AccountInactiveContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - User::samples(now, rng) - .into_iter() - .map(|user| AccountInactiveContext { user }) - .collect() + sample_list( + User::samples(now, rng) + .into_iter() + .map(|user| AccountInactiveContext { user }) + .collect(), + ) } } @@ -1692,17 +1837,21 @@ impl DeviceNameContext { } impl TemplateContext for DeviceNameContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - Client::samples(now, rng) + sample_list(Client::samples(now, rng) .into_iter() .map(|client| DeviceNameContext { client, raw_user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned(), }) - .collect() + .collect()) } } @@ -1714,16 +1863,25 @@ pub struct FormPostContext { } impl TemplateContext for FormPostContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng, locales: &[DataLocale]) -> Vec + fn sample( + now: chrono::DateTime, + rng: &mut impl Rng, + locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { let sample_params = T::sample(now, rng, locales); sample_params .into_iter() - .map(|params| FormPostContext { - redirect_uri: "https://example.com/callback".parse().ok(), - params, + .map(|(k, params)| { + ( + k, + FormPostContext { + redirect_uri: "https://example.com/callback".parse().ok(), + params, + }, + ) }) .collect() } @@ -1791,18 +1949,18 @@ impl TemplateContext for ErrorContext { _now: chrono::DateTime, _rng: &mut impl Rng, _locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new() .with_code("sample_error") .with_description("A fancy description".into()) .with_details("Something happened".into()), Self::new().with_code("another_error"), Self::new(), - ] + ]) } } @@ -1881,11 +2039,15 @@ impl NotFoundContext { } impl TemplateContext for NotFoundContext { - fn sample(_now: DateTime, _rng: &mut impl Rng, _locales: &[DataLocale]) -> Vec + fn sample( + _now: DateTime, + _rng: &mut impl Rng, + _locales: &[DataLocale], + ) -> BTreeMap where Self: Sized, { - vec![ + sample_list(vec![ Self::new(&Method::GET, Version::HTTP_11, &"/".parse().unwrap()), Self::new(&Method::POST, Version::HTTP_2, &"/foo/bar".parse().unwrap()), Self::new( @@ -1893,6 +2055,6 @@ impl TemplateContext for NotFoundContext { Version::HTTP_10, &"/foo?bar=baz".parse().unwrap(), ), - ] + ]) } } diff --git a/crates/templates/src/context/captcha.rs b/crates/templates/src/context/captcha.rs index 442cea4f8..3daafb745 100644 --- a/crates/templates/src/context/captcha.rs +++ b/crates/templates/src/context/captcha.rs @@ -4,7 +4,7 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use std::sync::Arc; +use std::{collections::BTreeMap, sync::Arc}; use mas_i18n::DataLocale; use minijinja::{ @@ -13,7 +13,7 @@ use minijinja::{ }; use serde::Serialize; -use crate::TemplateContext; +use crate::{TemplateContext, context::SampleIdentifier}; #[derive(Debug)] struct CaptchaConfig(mas_data_model::CaptchaConfig); @@ -62,14 +62,13 @@ impl TemplateContext for WithCaptcha { now: chrono::DateTime, rng: &mut impl rand::prelude::Rng, locales: &[DataLocale], - ) -> Vec + ) -> BTreeMap where Self: Sized, { - let inner = T::sample(now, rng, locales); - inner + T::sample(now, rng, locales) .into_iter() - .map(|inner| Self::new(None, inner)) + .map(|(k, inner)| (k, Self::new(None, inner))) .collect() } } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index ee8a6282e..603dcfdf6 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -9,7 +9,10 @@ //! Templates rendering -use std::{collections::HashSet, sync::Arc}; +use std::{ + collections::{BTreeMap, HashSet}, + sync::Arc, +}; use anyhow::Context as _; use arc_swap::ArcSwap; @@ -50,6 +53,7 @@ pub use self::{ }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; +use crate::context::SampleIdentifier; /// Escape the given string for use in HTML /// @@ -400,7 +404,7 @@ register_templates! { pub fn render_recovery_disabled(WithLanguage) { "pages/recovery/disabled.html" } /// Render the form used by the `form_post` response mode - pub fn render_form_post(WithLanguage>) { "form_post.html" } + pub fn render_form_post<#[sample(EmptyContext)] T: Serialize>(WithLanguage>) { "form_post.html" } /// Render the HTML error page pub fn render_error(ErrorContext) { "pages/error.html" } @@ -456,7 +460,13 @@ register_templates! { impl Templates { /// Render all templates with the generated samples to check if they render - /// properly + /// properly. + /// + /// Returns the renders in a map whose keys are template names + /// and the values are lists of renders (according to the list + /// of samples). + /// Samples are stable across re-runs and can be used for + /// acceptance testing. /// /// # Errors /// @@ -465,47 +475,8 @@ impl Templates { &self, now: chrono::DateTime, rng: &mut impl Rng, - ) -> anyhow::Result<()> { - check::render_not_found(self, now, rng)?; - check::render_app(self, now, rng)?; - check::render_swagger(self, now, rng)?; - check::render_swagger_callback(self, now, rng)?; - check::render_login(self, now, rng)?; - check::render_register(self, now, rng)?; - check::render_password_register(self, now, rng)?; - check::render_register_steps_verify_email(self, now, rng)?; - check::render_register_steps_email_in_use(self, now, rng)?; - check::render_register_steps_display_name(self, now, rng)?; - check::render_register_steps_registration_token(self, now, rng)?; - check::render_consent(self, now, rng)?; - check::render_policy_violation(self, now, rng)?; - check::render_sso_login(self, now, rng)?; - check::render_index(self, now, rng)?; - check::render_recovery_start(self, now, rng)?; - check::render_recovery_progress(self, now, rng)?; - check::render_recovery_finish(self, now, rng)?; - check::render_recovery_expired(self, now, rng)?; - check::render_recovery_consumed(self, now, rng)?; - check::render_recovery_disabled(self, now, rng)?; - check::render_form_post::(self, now, rng)?; - check::render_error(self, now, rng)?; - check::render_email_recovery_txt(self, now, rng)?; - check::render_email_recovery_html(self, now, rng)?; - check::render_email_recovery_subject(self, now, rng)?; - check::render_email_verification_txt(self, now, rng)?; - check::render_email_verification_html(self, now, rng)?; - check::render_email_verification_subject(self, now, rng)?; - check::render_upstream_oauth2_link_mismatch(self, now, rng)?; - check::render_upstream_oauth2_login_link(self, now, rng)?; - check::render_upstream_oauth2_suggest_link(self, now, rng)?; - check::render_upstream_oauth2_do_register(self, now, rng)?; - check::render_device_link(self, now, rng)?; - check::render_device_consent(self, now, rng)?; - check::render_account_deactivated(self, now, rng)?; - check::render_account_locked(self, now, rng)?; - check::render_account_logged_out(self, now, rng)?; - check::render_device_name(self, now, rng)?; - Ok(()) + ) -> anyhow::Result> { + check::all(self, now, rng) } } diff --git a/crates/templates/src/macros.rs b/crates/templates/src/macros.rs index e0b203735..95b57f0d9 100644 --- a/crates/templates/src/macros.rs +++ b/crates/templates/src/macros.rs @@ -31,7 +31,9 @@ macro_rules! register_templates { pub fn $name:ident // Optional list of generics. Taken from // https://newbedev.com/rust-macro-accepting-type-with-generic-parameters - $(< $( $lt:tt $( : $clt:tt $(+ $dlt:tt )* )? ),+ >)? + // For sample rendering, we also require a 'sample' generic parameter to be provided, + // using #[sample(Type)] attribute syntax + $(< $( #[sample( $generic_default:tt )] $lt:tt $( : $clt:tt $(+ $dlt:tt )* )? ),+ >)? // Type of context taken by the template ( $param:ty ) { @@ -69,28 +71,53 @@ macro_rules! register_templates { pub mod check { use super::*; + /// Check and render all templates with all samples. + /// + /// Returns the sample renders. The keys in the map are the template names. + /// + /// # Errors + /// + /// Returns an error if any template fails to render with any of the sample. + pub(crate) fn all(templates: &Templates, now: chrono::DateTime, rng: &mut impl rand::Rng) -> anyhow::Result<::std::collections::BTreeMap<(&'static str, SampleIdentifier), String>> { + let mut out = ::std::collections::BTreeMap::new(); + // TODO shouldn't the Rng be independent for each render? + $( + out.extend( + $name $(::< $( $generic_default ),* >)? (templates, now, rng)? + .into_iter() + .map(|(sample_identifier, rendered)| (($template, sample_identifier), rendered)) + ); + )* + + Ok(out) + } + $( #[doc = concat!("Render the `", $template, "` template with sample contexts")] /// + /// Returns the sample renders. + /// /// # Errors /// /// Returns an error if the template fails to render with any of the sample. pub(crate) fn $name $(< $( $lt $( : $clt $(+ $dlt )* + TemplateContext )? ),+ >)? (templates: &Templates, now: chrono::DateTime, rng: &mut impl rand::Rng) - -> anyhow::Result<()> { + -> anyhow::Result> { let locales = templates.translator().available_locales(); - let samples: Vec< $param > = TemplateContext::sample(now, rng, &locales); + let samples: BTreeMap = TemplateContext::sample(now, rng, &locales); let name = $template; - for sample in samples { + let mut out = BTreeMap::new(); + for (sample_identifier, sample) in samples { let context = serde_json::to_value(&sample)?; ::tracing::info!(name, %context, "Rendering template"); - templates. $name (&sample) - .with_context(|| format!("Failed to render template {:?} with context {}", name, context))?; + let rendered = templates. $name (&sample) + .with_context(|| format!("Failed to render sample template {name:?}-{sample_identifier:?} with context {context}"))?; + out.insert(sample_identifier, rendered); } - Ok(()) + Ok(out) } )* }