check: what the site mails, before anyone receives it #1
@@ -102,6 +102,21 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
println!("OK: access policy, {} rule(s), validates", policy.rules.len());
|
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 {
|
if show_access {
|
||||||
println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN");
|
println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN");
|
||||||
let mut rows = policy.rules.clone();
|
let mut rows = policy.rules.clone();
|
||||||
|
|||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
//! 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: it
|
||||||
|
//! is blank in the mails about the others;
|
||||||
|
//! - **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.
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String, Question>, 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<String, Question>) -> BTreeMap<String, Vec<(String, BTreeSet<String>)>> {
|
||||||
|
let mut out: BTreeMap<String, Vec<(String, BTreeSet<String>)>> = 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<String> {
|
||||||
|
let mut states: BTreeSet<String> = 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<String> {
|
||||||
|
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<String, Question>, aggregates: &HashMap<String, AggregateSchema>, site: &SiteConfig) -> Vec<Finding> {
|
||||||
|
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<String, BTreeMap<String, Vec<String>>> = 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 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::<Vec<_>>().join(", ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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<String> = 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<String> = 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<String, AggregateSchema>, site: &SiteConfig) -> usize {
|
||||||
|
aggregates.values().map(|s| s.mail_for_state.len()).sum::<usize>()
|
||||||
|
+ 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<String, Question>, HashMap<String, AggregateSchema>) {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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 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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
//! it matches, so the lint and the site never disagree about what a
|
//! it matches, so the lint and the site never disagree about what a
|
||||||
//! page is.
|
//! page is.
|
||||||
|
|
||||||
|
mod mail;
|
||||||
mod check;
|
mod check;
|
||||||
mod local;
|
mod local;
|
||||||
mod needs;
|
mod needs;
|
||||||
|
|||||||
Reference in New Issue
Block a user