crates/monetize-shop-pairing/src/sale_proof.rs
//! **The sale check, run from data** (A28 H12, v2.0 P3): a
//! [`SaleProof`](monetize_server::facts::SaleProof) (monetize-product-guest's `facts`) is the
//! product's own list of typed checks (a route, its method and body, and what its answer must
//! hold), declared by its guest; [`prove`] runs it over the sold front and [`first_door`] asks
//! its first-admin door. The shop holds no product's proof of its own.
//!
//! Each product names its own routes: there is no shared `/auth/first-admin`.
use monetize_server::facts::{ProofAsk, ProofExpect, ProofMethod, SaleProof};
use monetize_server::estate_order::FirstDoor;
use serde_json::Value;
use crate::sale_check::{excerpt, query, scrub, Front};
/// The words a check is asked with.
struct Words<'a> {
account: &'a str,
invite: &'a str,
bearer: &'a str,
}
impl Words<'_> {
/// The route, its `{invite}` and `{account}` percent-encoded.
fn path(&self, path: &str) -> String {
path.replace("{invite}", &query(self.invite)).replace("{account}", &query(self.account))
}
/// The body, its string leaves `{invite}` and `{account}` (trimmed) filled in.
fn body(&self, v: &Value) -> Value {
match v {
Value::String(s) => Value::String(s.replace("{invite}", self.invite).replace("{account}", self.account.trim())),
Value::Array(a) => Value::Array(a.iter().map(|x| self.body(x)).collect()),
Value::Object(o) => Value::Object(o.iter().map(|(k, x)| (k.clone(), self.body(x))).collect()),
other => other.clone(),
}
}
fn quiet(&self, s: &str) -> String {
scrub(s, &[self.invite, self.bearer])
}
}
/// `METHOD https://<host><path>`, the query withheld.
fn shown(host: &str, ask: &ProofAsk, path: &str) -> String {
let method = match ask.method {
ProofMethod::Get => "GET",
ProofMethod::Post => "POST",
};
match path.split_once('?') {
Some((p, _)) => format!("{method} https://{host}{p}?…"),
None => format!("{method} https://{host}{path}"),
}
}
/// One request, as `ask` says it.
fn send(front: &dyn Front, ask: &ProofAsk, w: &Words<'_>) -> Result<(u16, String), String> {
let path = w.path(&ask.path);
let bearer = ask.bearer.then_some(w.bearer);
match ask.method {
ProofMethod::Get => front.get(&path, bearer),
ProofMethod::Post => front.post(&path, &ask.body.as_ref().map(|b| w.body(b).to_string()).unwrap_or_default(), bearer),
}
}
/// Pure: does `answer` hold `e`? `Ok` carries what a list rule found, for the words the check
/// says (`exactly one alice/welcome`); `Err` says what the answer lacks. `json` is the body
/// read once.
fn holds(e: &ProofExpect, body: &str, json: Option<&Value>, w: &Words<'_>) -> Result<Option<String>, String> {
let json = || json.ok_or_else(|| "the answer is not JSON".to_string());
match e {
ProofExpect::Contains { text } => body.contains(text.as_str()).then_some(None).ok_or_else(|| format!("its answer does not hold {text}")),
ProofExpect::Equals { field, value } => {
let got = json()?.pointer(field).cloned().unwrap_or(Value::Null);
(got == *value).then_some(None).ok_or_else(|| format!("{field} is {got}, not {value}"))
}
ProofExpect::Text { field } => json()?.pointer(field).and_then(Value::as_str).filter(|s| !s.is_empty()).map(|_| None).ok_or_else(|| format!("{field} is no text (no passkey challenge)")),
ProofExpect::ExactlyOne { list, name_field, name, id_field, id } => {
let items = json()?.pointer(list).and_then(Value::as_array).ok_or_else(|| format!("{list} lists no items"))?;
let want = id.replace("{account}", w.account);
let named: Vec<&str> = items.iter().filter(|i| i[name_field.as_str()].as_str() == Some(name.as_str())).map(|i| i[id_field.as_str()].as_str().unwrap_or("?")).collect();
(named == [want.as_str()]).then(|| Some(format!("exactly one {want}"))).ok_or_else(|| format!("holds {} items named {name:?} ({}), not exactly {want}", named.len(), named.join(", ")))
}
ProofExpect::NotEmpty { list, name_field } => {
let items = json()?.pointer(list).and_then(Value::as_array).ok_or_else(|| format!("{list} carries no list"))?;
let names: Vec<&str> = items.iter().filter_map(|i| i[name_field.as_str()].as_str()).collect();
(!names.is_empty()).then(|| Some(format!("{} ({})", names.len(), names.join(", ")))).ok_or_else(|| format!("{list} lists nothing"))
}
}
}
/// **The sale check from data**: does `https://<host>` serve what was sold to `account`, by
/// `proof`'s checks in order? `Ok` says what it proved; `Err` names the first check that did
/// not hold (`<what>: …`). Neither ever says the invite or the bearer.
pub fn prove(proof: &SaleProof, front: &dyn Front, host: &str, account: &str, bearer: &str, invite: &str) -> Result<String, String> {
let w = Words { account, invite, bearer };
if proof.needs_account && account.trim().is_empty() {
return Err("no account was sold, so there is nothing to prove".into());
}
let mut proved = Vec::new();
for check in &proof.checks {
let path = w.path(&check.ask.path);
let shown = shown(host, &check.ask, &path);
let body = match send(front, &check.ask, &w) {
Ok((200, body)) => body,
Ok((status, body)) => return Err(w.quiet(&format!("{}: {shown} answered HTTP {status}: {}", check.what, excerpt(&body)))),
Err(e) => return Err(w.quiet(&format!("{}: {shown} did not answer: {e}", check.what))),
};
let json = serde_json::from_str::<Value>(&body).ok();
let mut found = Vec::new();
for e in &check.expect {
found.extend(holds(e, &body, json.as_ref(), &w).map_err(|why| w.quiet(&format!("{}: {shown}: {why}", check.what)))?);
}
let said = if found.is_empty() { check.what.clone() } else { format!("{} ({})", check.what, found.join(", ")) };
if !proved.contains(&said) {
proved.push(said);
}
}
Ok(format!("https://{host} serves {}", proved.join(", ")))
}
/// **The first-admin door from data**: spent,
/// open to `invite` (a passkey challenge, nothing spent), or closed to it. A product whose
/// proof has no first door is refused by name. Never says the invite.
pub fn first_door(proof: &SaleProof, front: &dyn Front, host: &str, account: &str, invite: &str) -> Result<FirstDoor, String> {
let d = proof.first_door.as_ref().ok_or_else(|| format!("REFUSED [sale-proof-no-first-door] https://{host}'s product names no first-admin door"))?;
let w = Words { account, invite, bearer: "" };
let answered = |what: &str, got: Result<(u16, String), String>| -> Result<(u16, String), String> { got.map_err(|e| w.quiet(&format!("{what} did not answer: {e}"))) };
let status_path = w.path(&d.status.path);
let (st, body) = answered(&format!("GET https://{host}{status_path}"), send(front, &d.status, &w))?;
if st != 200 {
return Err(w.quiet(&format!("GET https://{host}{status_path} answered HTTP {st}: {}", excerpt(&body))));
}
let status: Value = serde_json::from_str(&body).map_err(|e| format!("https://{host}{status_path} is not the console's status: {e}"))?;
if status.pointer(&d.spent) == Some(&Value::Bool(true)) {
return Ok(FirstDoor::Spent);
}
if status.pointer(&d.enabled) != Some(&Value::Bool(true)) {
return Ok(FirstDoor::Closed(format!("https://{host} offers no passkeys")));
}
let shown = shown(host, &d.start, &w.path(&d.start.path));
let challenged = |body: &str| serde_json::from_str::<Value>(body).ok().and_then(|v| v.pointer(&d.challenge).and_then(Value::as_str).map(|s| !s.is_empty())).unwrap_or(false);
match answered(&shown, send(front, &d.start, &w))? {
(200, body) if challenged(&body) => Ok(FirstDoor::Open),
(200, _) => Ok(FirstDoor::Closed(format!("{shown} gave no passkey challenge for the invite"))),
(st, body) => Ok(FirstDoor::Closed(w.quiet(&format!("{shown} answered HTTP {st}: {}", excerpt(&body))))),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A front that answers each path from a table and records what it was asked.
struct Table(Vec<(&'static str, u16, &'static str)>, std::sync::Mutex<Vec<(String, String, Option<String>)>>);
impl Front for Table {
fn get(&self, p: &str, b: Option<&str>) -> Result<(u16, String), String> {
self.1.lock().unwrap().push(("GET".into(), p.into(), b.map(str::to_string)));
self.0.iter().find(|(path, _, _)| *path == p).map(|(_, s, body)| (*s, body.to_string())).ok_or_else(|| "connection refused".into())
}
fn post(&self, p: &str, body: &str, b: Option<&str>) -> Result<(u16, String), String> {
self.1.lock().unwrap().push(("POST".into(), format!("{p} {body}"), b.map(str::to_string)));
self.get(p, b)
}
}
fn table(rows: Vec<(&'static str, u16, &'static str)>) -> Table {
Table(rows, Default::default())
}
/// Each expectation holds or says what the answer lacks; a check that reads JSON fails on
/// a body that is not.
use monetize_server::facts::ProofCheck;
#[test]
fn each_expectation_holds_or_says_what_is_missing() {
let w = Words { account: "alice", invite: "i", bearer: "b" };
let j = serde_json::json!({"a": true, "c": {"d": "x"}, "items": [{"name": "welcome", "path": "alice/welcome"}, {"name": "w", "path": "bob/w"}], "r": [{"name": "crates"}], "e": []});
let ok = |e: ProofExpect| holds(&e, "page", Some(&j), &w);
assert!(ok(ProofExpect::Contains { text: "pa".into() }).is_ok() && ok(ProofExpect::Contains { text: "zz".into() }).is_err());
assert_eq!(ok(ProofExpect::NotEmpty { list: "/r".into(), name_field: "name".into() }), Ok(Some("1 (crates)".into())), "a list rule says what it found");
assert!(ok(ProofExpect::Equals { field: "/a".into(), value: Value::Bool(true) }).is_ok());
assert!(ok(ProofExpect::Equals { field: "/missing".into(), value: Value::Bool(false) }).is_err(), "an absent field is null, not false");
assert!(ok(ProofExpect::Text { field: "/c/d".into() }).is_ok() && ok(ProofExpect::Text { field: "/a".into() }).is_err());
let one = |id: &str| ProofExpect::ExactlyOne { list: "/items".into(), name_field: "name".into(), name: "welcome".into(), id_field: "path".into(), id: id.into() };
assert!(ok(one("{account}/welcome")).is_ok());
assert!(ok(one("bob/welcome")).unwrap_err().contains("not exactly bob/welcome"));
assert!(ok(ProofExpect::NotEmpty { list: "/r".into(), name_field: "name".into() }).is_ok());
assert!(ok(ProofExpect::NotEmpty { list: "/e".into(), name_field: "name".into() }).is_err());
assert!(holds(&ProofExpect::Text { field: "/a".into() }, "<html>", None, &w).unwrap_err().contains("not JSON"));
}
/// The method and body check: a `POST` carries its body with `{invite}` and the trimmed
/// `{account}` filled in, a route its percent-encoded words, and only a bearer check sends
/// the bearer.
#[test]
fn a_check_is_asked_by_its_method_body_and_bearer() {
let w = Words { account: " al ice ", invite: "in/v", bearer: "BEARER-TOKEN" };
assert_eq!(w.path("/s?invite={invite}&user={account}"), "/s?invite=in%2Fv&user=%20al%20ice%20");
assert_eq!(w.body(&serde_json::json!({"invite": "{invite}", "display": "{account}", "n": [1, "{account}"]})), serde_json::json!({"invite": "in/v", "display": "al ice", "n": [1, "al ice"]}));
let front = table(vec![("/p", 200, "{}"), ("/g", 200, "{}")]);
send(&front, &ProofAsk { method: ProofMethod::Post, path: "/p".into(), body: Some(serde_json::json!({"i": "{invite}"})), bearer: false }, &w).unwrap();
send(&front, &ProofAsk { method: ProofMethod::Get, path: "/g".into(), body: None, bearer: true }, &w).unwrap();
let asked = front.1.lock().unwrap().clone();
assert_eq!(asked[0], ("POST".into(), "/p {\"i\":\"in/v\"}".into(), None));
assert_eq!(asked[2], ("GET".into(), "/g".into(), Some("BEARER-TOKEN".into())));
}
/// A product's own routes: a proof of the Lake's shape (`/login`, `/first-login`), with no
/// `/auth/first-admin` anywhere, is proven by those routes alone; a proof with no first door
/// is refused by name when its first door is asked.
#[test]
fn a_product_names_its_own_routes() {
let proof = SaleProof {
checks: vec![
ProofCheck { what: "the login page".into(), ask: ProofAsk { method: ProofMethod::Get, path: "/login".into(), body: None, bearer: false }, expect: vec![] },
ProofCheck { what: "the first-login door".into(), ask: ProofAsk { method: ProofMethod::Get, path: "/first-login".into(), body: None, bearer: false }, expect: vec![ProofExpect::Equals { field: "/open".into(), value: Value::Bool(true) }] },
],
needs_account: true,
first_door: None,
};
let front = table(vec![("/login", 200, "ok"), ("/first-login", 200, r#"{"open":true}"#)]);
assert!(prove(&proof, &front, "lake.test", "alice", "b", "i").unwrap().contains("the first-login door"));
let shut = table(vec![("/login", 200, "ok"), ("/first-login", 200, r#"{"open":false}"#)]);
assert!(prove(&proof, &shut, "lake.test", "alice", "b", "i").unwrap_err().starts_with("the first-login door:"));
assert!(first_door(&proof, &front, "lake.test", "alice", "i").unwrap_err().contains("[sale-proof-no-first-door]"));
for p in [monetize_server::testing::lifecycles::gunnar_facts().proof.unwrap(), monetize_server::testing::lifecycles::holger_facts().proof.unwrap()] {
let routes = serde_json::to_string(&p).unwrap();
assert!(!routes.contains("/auth/first-admin"), "no shared first-admin route");
}
}
}