diff --git a/src/check.rs b/src/check.rs index c3e8ab7..3758e21 100644 --- a/src/check.rs +++ b/src/check.rs @@ -102,6 +102,21 @@ pub async fn run(argv: Vec) -> anyhow::Result<()> { std::process::exit(1); } println!("OK: access policy, {} rule(s), validates", policy.rules.len()); + // What the site mails, checked before anyone receives it: + // placeholders that would go out blank, links to pages + // that are not there, grants and invites nobody hears of. + let findings = crate::mail::check(&questions, &aggregates_map, &site); + for f in &findings { + let tag = if f.level == needs::Level::Fail { "FAIL" } else { "WARN" }; + eprintln!("{tag}: {}", f.text); + } + if findings.iter().any(|f| f.level == needs::Level::Fail) { + std::process::exit(1); + } + let mails = crate::mail::count(&aggregates_map, &site); + if mails > 0 { + println!("OK: {mails} mail(s): every placeholder fills, every link lands"); + } if show_access { println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN"); let mut rows = policy.rules.clone(); diff --git a/src/mail.rs b/src/mail.rs new file mode 100644 index 0000000..7d82558 --- /dev/null +++ b/src/mail.rs @@ -0,0 +1,327 @@ +//! What the site's mail promises, checked before it is sent to anyone. +//! +//! A mail is read alone, away from the page that caused it, by a +//! person who cannot ask what a blank means. Portal renders a `{key}` +//! it has no value for as nothing, silently, and sends a link to a +//! page that is not there as readily as one that is. So the things a +//! mail can get wrong without any runtime error are checked here: +//! +//! - **fail** a placeholder no record in the bucket can fill: the mail +//! goes out with a hole in it; +//! - **fail** a link to a page the site does not have; +//! - **fail** a state that grants a group on a site that sends no mail: +//! the account is made and its sign-in link reaches nobody; +//! - **fail** an invite mail without its `{link}`, or with a +//! placeholder the invite cannot fill; +//! - **warn** a placeholder only some of the bucket's forms collect, or +//! one every form marks optional: it is blank in the mails about the +//! others, or about anyone who skipped it; +//! - **warn** a dialogue that does not close: the person is mailed on +//! the way in and not told how their case ended; +//! - **warn** a mail that tells a person their case is where they may +//! now act on it, and gives them no link to do so; +//! - **warn** a long answer (a `textarea` field) put in a subject line, +//! which becomes the whole brief in someone's inbox list. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use portal::aggregates::AggregateSchema; +use portal::content::{self, Question, SiteConfig}; + +use crate::needs::Level; + +/// What portal fills in a case mail besides the record's own answers +/// (`mail.rs`, `vars_for`). +const CASE_VARS: &[&str] = &["email", "site", "chain", "bucket", "state"]; +/// What portal fills in the invite mail (`mail.rs`, `on_invite`). +const INVITE_VARS: &[&str] = &["name", "username", "email", "group", "link", "site", "site_name", "expires"]; + +#[derive(Debug)] +pub struct Finding { + pub level: Level, + pub text: String, +} + +/// `{key}` exactly as portal's renderer reads one: ASCII alphanumerics, +/// `_`, `-` and `.`, closed by `}`. A brace that opens no key is text. +pub fn placeholders(template: &str) -> Vec { + let mut keys = Vec::new(); + let mut rest = template; + while let Some(start) = rest.find('{') { + let after = &rest[start + 1..]; + let len = after + .char_indices() + .take_while(|(_, c)| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); + if len > 0 && after[len..].starts_with('}') { + keys.push(after[..len].to_string()); + rest = &after[len + 1..]; + } else { + rest = after; + } + } + keys +} + +/// The paths a template links to on its own site: `{site}/x/y?...`. +fn site_links(template: &str) -> Vec { + template + .match_indices("{site}") + .map(|(i, _)| { + let tail = &template[i + "{site}".len()..]; + let end = tail.find(|c: char| c.is_whitespace() || matches!(c, '?' | '#' | ')' | '>' | '"' | '\'')).unwrap_or(tail.len()); + let path = tail[..end].trim_end_matches(['.', ',', ';', ':']); + if path.is_empty() { "/".to_string() } else { path.to_string() } + }) + .collect() +} + +fn page_exists(questions: &HashMap, path: &str) -> bool { + let path = path.trim_end_matches('/'); + let path = if path.is_empty() { "/" } else { path }; + questions.contains_key(path) + || questions.values().any(|q| q.is_dynamic() && content::path_matches(&q.id, path).is_some()) +} + +/// For each bucket, the field names of every alternative that records +/// into it: one set per form. +fn forms_by_bucket(questions: &HashMap) -> BTreeMap)>> { + let mut out: BTreeMap)>> = BTreeMap::new(); + for q in questions.values() { + for alt in &q.alternatives { + if let Some(bucket) = &alt.record_as { + let fields = alt.features.iter().flat_map(|f| &f.requirements).map(|r| r.name.clone()).collect(); + out.entry(bucket.clone()).or_default().push((format!("{} / {}", q.id, alt.name), fields)); + } + } + } + out +} + +/// States with no move out of them: where a case ends. +fn endings(schema: &AggregateSchema) -> BTreeSet { + let mut states: BTreeSet = schema.event_for_state.keys().cloned().collect(); + states.insert(schema.initial.clone()); + states.into_iter().filter(|s| schema.allowed(s).is_empty()).collect() +} + +/// States reachable from `from` along declared moves, `from` included. +fn reachable(schema: &AggregateSchema, from: &str) -> BTreeSet { + let mut seen = BTreeSet::from([from.to_string()]); + let mut todo = vec![from.to_string()]; + while let Some(s) = todo.pop() { + for next in schema.allowed(&s) { + if seen.insert(next.clone()) { + todo.push(next.clone()); + } + } + } + seen +} + +pub fn check(questions: &HashMap, aggregates: &HashMap, site: &SiteConfig) -> Vec { + let mut findings = Vec::new(); + let mut fail = |text: String| findings.push(Finding { level: Level::Fail, text }); + let forms = forms_by_bucket(questions); + let mut warns = Vec::new(); + + // Where the submitter may act on their own record: bucket -> state + // -> the labels of the moves they may make from there. + let mut own_moves: BTreeMap>> = BTreeMap::new(); + for q in questions.values() { + for alt in &q.alternatives { + if let Some(st) = &alt.self_transition { + if let Some(schema) = aggregates.get(&st.bucket) { + for from in st.from_states(&schema.initial) { + own_moves.entry(st.bucket.clone()).or_default().entry(from).or_default().push(st.label.clone()); + } + } + } + } + } + + let mut buckets: Vec<&AggregateSchema> = aggregates.values().collect(); + buckets.sort_by(|a, b| a.bucket.cmp(&b.bucket)); + for schema in buckets { + let bucket = &schema.bucket; + let bucket_forms = forms.get(bucket).cloned().unwrap_or_default(); + let mut states: Vec<(&String, &portal::aggregates::MailSpec)> = schema.mail_for_state.iter().collect(); + states.sort_by(|a, b| a.0.cmp(b.0)); + + for (state, mail) in &states { + let at = format!("mail on {bucket}:{state}"); + for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) { + if CASE_VARS.contains(&key.as_str()) { + continue; + } + let having: Vec<&String> = bucket_forms.iter().filter(|(_, f)| f.contains(&key)).map(|(n, _)| n).collect(); + if having.is_empty() { + fail(format!("{at}: {{{key}}} is not a field any form recording into {bucket:?} collects, nor one portal fills ({}) - it would be sent blank", CASE_VARS.join(", "))); + } else if questions + .values() + .flat_map(|q| &q.alternatives) + .filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) + .flat_map(|a| a.features.iter().flat_map(|f| &f.requirements)) + .filter(|r| r.name == key) + .all(|r| r.optional) + { + warns.push(format!("{at}: {{{key}}} is optional on every form - blank for anyone who skipped it")); + } else if having.len() < bucket_forms.len() { + let missing: Vec<&String> = bucket_forms.iter().filter(|(_, f)| !f.contains(&key)).map(|(n, _)| n).collect(); + warns.push(format!("{at}: {{{key}}} is blank for records sent from {}", missing.iter().map(|n| format!("{n:?}")).collect::>().join(", "))); + } + } + for key in placeholders(&mail.subject) { + let long = questions.values().flat_map(|q| &q.alternatives).filter(|a| a.record_as.as_deref() == Some(bucket.as_str())).flat_map(|a| a.features.iter().flat_map(|f| &f.requirements)).any(|r| r.name == key && r.kind == "textarea"); + if long { + warns.push(format!("{at}: the subject carries {{{key}}}, a long answer (textarea) - the whole of it lands in the subject line")); + } + } + for path in site_links(&mail.body).into_iter().chain(site_links(&mail.subject)) { + if path.contains('{') { + continue; // filled per record; the page is checked where the pattern is declared + } + if !page_exists(questions, &path) { + fail(format!("{at}: links to {path:?}, which is not a page on this site")); + } + } + } + + if !schema.grants_for_state.is_empty() && site.mail.is_none() { + let mut granting: Vec<&String> = schema.grants_for_state.keys().collect(); + granting.sort(); + fail(format!("{bucket}: state(s) {granting:?} grant a group, and site.yaml has no `mail: {{ from }}` - the account would be made and its sign-in link sent to nobody")); + } + + // The dialogue closes: a person mailed on the way in hears how + // it ended, from every ending their case can still reach. + let to_submitter: BTreeSet<&String> = states.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| *s).collect(); + if let Some(first) = to_submitter.iter().min_by_key(|s| (s.as_str() != schema.initial, s.to_string())) { + let reach = reachable(schema, first); + let silent: Vec = endings(schema).into_iter().filter(|e| reach.contains(e) && !to_submitter.contains(e)).collect(); + if !silent.is_empty() { + warns.push(format!("{bucket}: the person is mailed at {first:?} and not told when their case ends at {silent:?}")); + } + } + + // A mail telling a person their case is where they may act on + // it carries the link that lets them. + for (state, mail) in &states { + if !mail.to_submitter() { + continue; + } + if let Some(labels) = own_moves.get(bucket).and_then(|m| m.get(*state)) { + if !mail.body.contains("{chain}") { + warns.push(format!("mail on {bucket}:{state}: the person may now {:?} themselves, and the mail gives them no link (`{{chain}}`) to do it", labels)); + } + } + } + } + + if let Some(invite) = site.mail.as_ref().and_then(|m| m.invite.as_ref()) { + let keys: Vec = placeholders(&invite.subject).into_iter().chain(placeholders(&invite.body)).collect(); + for key in &keys { + if !INVITE_VARS.contains(&key.as_str()) { + fail(format!("site.yaml mail.invite: {{{key}}} is not something the invite mail knows ({}) - it would be sent blank", INVITE_VARS.join(", "))); + } + } + if !invite.body.contains("{link}") { + fail("site.yaml mail.invite: the body has no {link} - the person invited could never sign in".to_string()); + } + } + + findings.extend(warns.into_iter().map(|text| Finding { level: Level::Warn, text })); + findings +} + +/// How many mails the site declares, invite included. +pub fn count(aggregates: &HashMap, site: &SiteConfig) -> usize { + aggregates.values().map(|s| s.mail_for_state.len()).sum::() + + usize::from(site.mail.as_ref().is_some_and(|m| m.invite.is_some())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn placeholders_are_read_the_way_portal_renders_them() { + assert_eq!(placeholders("Hi {name}, see {site}/decide/x?chain={chain}. {not a key} {}"), ["name", "site", "chain"]); + assert_eq!(site_links("Open {site}/decide/trial?chain={chain}. Or {site}."), ["/decide/trial", "/"]); + } + + fn site(yaml: &str) -> SiteConfig { + serde_yaml::from_str(yaml).unwrap() + } + + fn repo(aggregates: &str, pages: &[(&str, &str)]) -> (HashMap, HashMap) { + let aggregates = portal::aggregates::parse_aggregates_yaml(aggregates).unwrap(); + let questions = pages + .iter() + .map(|(id, yaml)| { + let mut q: Question = serde_yaml::from_str(yaml).unwrap(); + q.id = id.to_string(); + (id.to_string(), q) + }) + .collect(); + (questions, aggregates) + } + + const AGG: &str = "aggregates:\n - bucket: trials\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: \"Got it, {name}\", body: \"Withdraw at {site}/decide/trial?chain={chain}\" } }\n approved: { event: approved, mail: { to: submitter, subject: Yes, body: \"{business} is approved\" } }\n declined: { event: declined }\n withdrawn: { event: withdrawn }\n transitions:\n open: [approved, declined, withdrawn]\n"; + const FORM: &str = "name: Q?\nalternatives:\n - name: Try it\n record_as: trials\n features:\n - name: f\n requirements:\n - { name: name }\n - { name: email, type: email }\n"; + const DECIDE: &str = "name: Decide?\nalternatives:\n - name: Withdraw\n self_transition: { bucket: trials, to: withdrawn, label: Withdraw }\n"; + + #[test] + fn a_blank_placeholder_a_dead_link_and_a_silent_ending_are_found() { + let (q, a) = repo(AGG, &[("/", FORM), ("/decide/trial", DECIDE)]); + let f = check(&q, &a, &site("mail: { from: x@y.no }\n")); + let text = |lvl: Level| f.iter().filter(|x| x.level == lvl).map(|x| x.text.clone()).collect::>(); + let fails = text(Level::Fail); + assert_eq!(fails.len(), 1, "{fails:?}"); + assert!(fails[0].contains("{business}")); + let warns = text(Level::Warn); + assert!(warns.iter().any(|w| w.contains("declined") && w.contains("withdrawn")), "{warns:?}"); + + let (q, a) = repo(AGG, &[("/", FORM)]); + let f = check(&q, &a, &site("mail: { from: x@y.no }\n")); + assert!(f.iter().any(|x| x.level == Level::Fail && x.text.contains("/decide/trial"))); + } + + #[test] + fn a_grant_needs_a_sender_and_an_invite_needs_its_link() { + let agg = "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received }\n in: { event: in, grants: members }\n transitions:\n open: [in]\n"; + let (q, a) = repo(agg, &[("/", "name: Q?\nalternatives:\n - name: A\n record_as: b\n features:\n - name: f\n requirements:\n - { name: name }\n - { name: email, type: email }\n")]); + assert!(check(&q, &a, &site("title: T\n")).iter().any(|x| x.level == Level::Fail && x.text.contains("sent to nobody"))); + let f = check(&q, &a, &site("mail: { from: x@y.no, invite: { subject: \"Hi {name}\", body: \"Welcome to {site_name}, {nickname}\" } }\n")); + assert!(f.iter().any(|x| x.text.contains("{nickname}"))); + assert!(f.iter().any(|x| x.text.contains("no {link}"))); + } + + #[test] + fn an_optional_answer_in_a_mail_is_found() { + let agg = AGG.replace("Got it, {name}", "Got it, {nick}"); + let form = FORM.replace(" - { name: email, type: email }\n", " - { name: email, type: email }\n - { name: nick, optional: true }\n"); + let (q, a) = repo(&agg, &[("/", &form), ("/decide/trial", DECIDE)]); + let f = check(&q, &a, &site("mail: { from: x@y.no }\n")); + assert!(f.iter().any(|x| x.level == Level::Warn && x.text.contains("{nick}") && x.text.contains("optional")), "{f:?}"); + } + + #[test] + fn a_long_answer_in_a_subject_line_is_found() { + let agg = AGG.replace("Got it, {name}", "About {brief}"); + let form = FORM.replace(" - { name: email, type: email }\n", " - { name: email, type: email }\n - { name: brief, type: textarea }\n"); + let (q, a) = repo(&agg, &[("/", &form), ("/decide/trial", DECIDE)]); + let f = check(&q, &a, &site("mail: { from: x@y.no }\n")); + assert!(f.iter().any(|x| x.level == Level::Warn && x.text.contains("{brief}") && x.text.contains("subject")), "{f:?}"); + } + + #[test] + fn a_mail_about_a_case_the_person_may_act_on_links_to_it() { + let agg = AGG.replace("Withdraw at {site}/decide/trial?chain={chain}", "We have it"); + let (q, a) = repo(&agg, &[("/", FORM), ("/decide/trial", DECIDE)]); + let f = check(&q, &a, &site("mail: { from: x@y.no }\n")); + assert!(f.iter().any(|x| x.level == Level::Warn && x.text.contains("no link")), "{f:?}"); + } +} diff --git a/src/main.rs b/src/main.rs index 2650810..f1eef7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ //! it matches, so the lint and the site never disagree about what a //! page is. +mod mail; mod check; mod local; mod needs;