crates/monetize-cli/tests/front_certs.rs
//! ★ **`monetize front certs --at https://<site>/<its certificate door>`** (A7, the ACME lane's
//! leftover): the ONE box has `monetize box certs`; Holger's and Gunnar's fronts showed their
//! certificates only on their console pages. The verb is a thin client over the status doors
//! the ACME lane added — Holger's `GET/POST /front/certs[/try]`, Gunnar's
//! `GET/POST /api/front/certs[/try]` — with an admin's credential read from a file, and the
//! front's own words printed. Nothing is decided here. RA1-3: the door is the address the
//! operator names; the CLI holds no product's door path.
//!
//! The front here is a TLS server in the test answering with a report in edda estate-front's
//! shape (`web::status::report_json`: monetize-admin's `tests/front_certs_cli.rs` renders the
//! REAL one); the CLI is the real `monetize` binary.
use std::io::{BufRead as _, BufReader, Read as _, Write as _};
use std::net::TcpListener;
use std::path::Path;
use std::sync::{Arc, Mutex};
/// One request the front saw: method, path, `authorization`, `cookie`, body.
type Seen = Arc<Mutex<Vec<(String, String, String, String, String)>>>;
const TOKEN: &str = "holger-admin-token-mock-never-printed";
const COOKIE: &str = "gunnar_session=mock-admin-session-never-printed";
/// **The front**: TLS on loopback under its own self-signed certificate (written to
/// `ca.pem` for `--ca`), answering `report` to an admin's read and `{"words"}` to a try now,
/// and the front's own refusal to anyone else.
fn front(dir: &Path, report: serde_json::Value) -> (String, Seen) {
let ck = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).unwrap();
std::fs::write(dir.join("ca.pem"), ck.cert.pem()).unwrap();
let certs = vec![ck.cert.der().clone()];
let key = rustls::pki_types::PrivateKeyDer::try_from(ck.signing_key.serialize_der()).unwrap();
let cfg = Arc::new(rustls::ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())).with_safe_default_protocol_versions().unwrap().with_no_client_auth().with_single_cert(certs, key).unwrap());
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let at = format!("https://127.0.0.1:{}", l.local_addr().unwrap().port());
let seen: Seen = Arc::default();
let s = seen.clone();
std::thread::spawn(move || {
for tcp in l.incoming().flatten() {
let (cfg, s, report) = (cfg.clone(), s.clone(), report.clone());
std::thread::spawn(move || {
let conn = rustls::ServerConnection::new(cfg).unwrap();
let mut tls = rustls::StreamOwned::new(conn, tcp);
let mut r = BufReader::new(&mut tls);
let mut line = String::new();
if r.read_line(&mut line).is_err() {
return;
}
let mut p = line.split_whitespace();
let (method, path) = (p.next().unwrap_or("").to_string(), p.next().unwrap_or("").to_string());
let (mut len, mut auth, mut cookie) = (0usize, String::new(), String::new());
loop {
let mut h = String::new();
if r.read_line(&mut h).is_err() || h.trim().is_empty() {
break;
}
let (k, v) = h.split_once(':').unwrap_or((&h, ""));
match k.trim().to_ascii_lowercase().as_str() {
"content-length" => len = v.trim().parse().unwrap_or(0),
"authorization" => auth = v.trim().to_string(),
"cookie" => cookie = v.trim().to_string(),
_ => {}
}
}
let mut body = vec![0u8; len];
let _ = r.read_exact(&mut body);
let body = String::from_utf8_lossy(&body).to_string();
s.lock().unwrap().push((method.clone(), path.clone(), auth.clone(), cookie.clone(), body.clone()));
let admin = auth == format!("Bearer {TOKEN}") || cookie == COOKIE;
let (status, text) = if !admin {
(401, "REFUSED [front-certs-signed-out] sign in as an admin\n".to_string())
} else if method == "POST" && path.ends_with("/certs/try") {
let host = serde_json::from_str::<serde_json::Value>(&body).unwrap()["host"].as_str().unwrap_or_default().to_string();
(200, serde_json::json!({"words": format!("{host}: tried now")}).to_string())
} else if method == "GET" && path.ends_with("/certs") {
(200, report.to_string())
} else {
(404, "no such door\n".to_string())
};
let _ = write!(tls, "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{text}", text.len());
let _ = tls.flush();
});
}
});
(at, seen)
}
fn monetize(args: &[&str]) -> (bool, String, String) {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_monetize")).args(args).env_remove("MONETIZE_DOOR").env_remove("MONETIZE_MATERIAL").output().unwrap();
(out.status.success(), String::from_utf8_lossy(&out.stdout).to_string(), String::from_utf8_lossy(&out.stderr).to_string())
}
#[test]
fn front_certs_shows_holgers_and_gunnars_certificates_through_their_own_doors() {
let dir = tempfile::tempdir().unwrap();
let report = serde_json::json!({"certificates": [{
"host": "holger.example", "hosts": ["holger.example", "www.holger.example"],
"valid_until": null, "next_attempt": 1_761_480_000, "failures": 1, "try_now": false, "alert": null,
"lines": ["no valid certificate yet", "next attempt in 59 min (1 failed validation)", "last: dns: holger.example does not point at this box (it resolves to 198.51.100.9)"],
}]});
let lines: Vec<String> = report["certificates"][0]["lines"].as_array().expect("the report has lines").iter().filter_map(|l| l.as_str().map(String::from)).collect();
assert!(!lines.is_empty(), "{report}");
let (at, seen) = front(dir.path(), report);
let ca = dir.path().join("ca.pem").display().to_string();
let token = dir.path().join("token");
std::fs::write(&token, format!("{TOKEN}\n")).unwrap();
let cookie = dir.path().join("cookie");
std::fs::write(&cookie, COOKIE).unwrap();
let token = token.display().to_string();
let cookie = cookie.display().to_string();
// Holger: its admin's bearer on /front/certs; the front's lines printed, each.
let (ok, out, err) = monetize(&["front", "certs", "--at", &format!("{at}/front/certs"), "--ca", &ca, "--token-file", &token]);
assert!(ok, "{out}{err}");
assert!(out.contains("holger.example"), "{out}");
for l in &lines {
assert!(out.contains(l.as_str()), "the front's line {l:?} is said as it said it: {out}");
}
// Gunnar: the console's admin session on /api/front/certs.
let (ok, out, err) = monetize(&["front", "certs", "--at", &format!("{at}/api/front/certs"), "--ca", &ca, "--cookie-file", &cookie]);
assert!(ok && out.contains("holger.example"), "{out}{err}");
// Try now, with the host named.
let (ok, out, err) = monetize(&["front", "certs", "--at", &format!("{at}/front/certs"), "--ca", &ca, "--token-file", &token, "--try", "holger.example"]);
assert!(ok && out.contains("holger.example: tried now"), "{out}{err}");
// No admin's credential: the front's own refusal, and a failure.
let wrong = dir.path().join("wrong");
std::fs::write(&wrong, "not-the-token").unwrap();
let (ok, out, err) = monetize(&["front", "certs", "--at", &format!("{at}/front/certs"), "--ca", &ca, "--token-file", &wrong.display().to_string()]);
assert!(!ok && err.contains("[front-certs-signed-out]"), "{out}{err}");
let seen = seen.lock().unwrap().clone();
let doors: Vec<(String, String)> = seen.iter().map(|(m, p, ..)| (m.clone(), p.clone())).collect();
assert_eq!(doors, [("GET", "/front/certs"), ("GET", "/api/front/certs"), ("POST", "/front/certs/try"), ("GET", "/front/certs")].map(|(m, p)| (m.to_string(), p.to_string())).to_vec());
assert_eq!(seen[0].2, format!("Bearer {TOKEN}"), "the token file's line, trimmed, as the bearer");
assert_eq!((seen[1].2.as_str(), seen[1].3.as_str()), ("", COOKIE), "the session file as the cookie, no bearer");
assert_eq!(serde_json::from_str::<serde_json::Value>(&seen[2].4).unwrap(), serde_json::json!({"host": "holger.example"}));
let (_, o, e) = monetize(&["front", "certs", "--at", &format!("{at}/front/certs"), "--ca", &ca, "--token-file", &token]);
assert!(!o.contains(TOKEN) && !e.contains(TOKEN), "the credential is never said");
}
/// Refused by name before anything is asked: an address that names no door (or the old form
/// that named a product), no `--at`, a plain-http address, and no credential.
#[test]
fn front_certs_refuses_what_it_cannot_ask_by_name() {
for (args, why) in [
(vec!["front", "certs", "--at", "https://x.example", "--token-file", "/dev/null"], "[front-door]"),
(vec!["front", "certs", "someproduct", "--at", "https://x.example/front/certs", "--token-file", "/dev/null"], "[front-door]"),
(vec!["front", "certs", "--token-file", "/dev/null"], "[front-at]"),
(vec!["front", "certs", "--at", "http://x.example/front/certs", "--token-file", "/dev/null"], "[front-at]"),
(vec!["front", "certs", "--at", "https://x.example/api/front/certs"], "[front-credential]"),
] {
let (ok, out, err) = monetize(&args);
assert!(!ok && err.contains(why), "{args:?}: {out}{err}");
}
}