crates/monetize-cli/tests/door_client.rs
//! ★ **The box's door through the one HTTPS client** (RA1-4): `monetize box certs` over the
//! real mutual TLS (nordisk-pairing's installer CA, the operator's certificate from the
//! material directory), against a door that answers CHUNKED as hyper does. The one client
//! reads both the door and a product's front with the same parser, so the door's chunked
//! answer is put back together (the door's own reader before RA1-4 kept the chunk sizes).
use std::io::{BufRead as _, BufReader, Write as _};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use nordisk_pairing::installer::{CA_CERT, INSTALLER_CERT, INSTALLER_KEY};
use nordisk_pairing::mtls::{self, InstallerCa};
/// What the door saw: each client's CN and request line.
type Seen = Arc<Mutex<Vec<(Option<String>, String)>>>;
/// The door: mTLS under `ca`, every client's CN and request line seen, the answer chunked.
fn door(ca: &InstallerCa, body: &'static str) -> (String, Seen) {
let served = ca.issue_bootstrap(Duration::from_secs(3600)).unwrap();
let cfg = mtls::server_config(&ca.cert_pem, &served).unwrap();
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let at = format!("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) = (cfg.clone(), s.clone());
std::thread::spawn(move || {
let conn = rustls::ServerConnection::new(cfg).unwrap();
let mut tls = rustls::StreamOwned::new(conn, tcp);
let mut line = String::new();
{
let mut r = BufReader::new(&mut tls);
if r.read_line(&mut line).is_err() {
return;
}
loop {
let mut h = String::new();
if r.read_line(&mut h).is_err() || h.trim().is_empty() {
break;
}
}
}
s.lock().unwrap().push((mtls::peer_cn(&tls.conn), line.trim().to_string()));
let (a, b) = body.split_at(body.len() / 2);
let _ = write!(tls, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ntransfer-encoding: chunked\r\n\r\n{:x}\r\n{a}\r\n{:x}\r\n{b}\r\n0\r\n\r\n", a.len(), b.len());
tls.conn.send_close_notify();
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 box_certs_reads_a_chunked_door_answer_over_the_operators_mutual_tls() {
let ca = InstallerCa::mint("ra1-door", Duration::from_secs(3600)).unwrap();
let me = ca.issue_installer(Duration::from_secs(3600)).unwrap();
let material = tempfile::tempdir().unwrap();
std::fs::write(material.path().join(CA_CERT), &ca.cert_pem).unwrap();
std::fs::write(material.path().join(INSTALLER_CERT), &me.cert_pem).unwrap();
std::fs::write(material.path().join(INSTALLER_KEY), &me.key_pem).unwrap();
let report = r#"{"certificates":[{"host":"door.example","hosts":["door.example"],"lines":["valid until 2027-01-01","next attempt in 59 days"]}]}"#;
let (at, seen) = door(&ca, report);
let m = material.path().display().to_string();
let (ok, out, err) = monetize(&["--door", &at, "--material", &m, "box", "certs"]);
assert!(ok, "{out}{err}");
assert_eq!(out, "door.example (door.example)\n valid until 2027-01-01\n next attempt in 59 days\n", "the chunked answer put back together");
let seen = seen.lock().unwrap().clone();
assert_eq!(seen, [(Some(mtls::INSTALLER_CN.to_string()), "GET /self/certs HTTP/1.1".to_string())], "the operator's certificate, one request");
// A client certificate from another CA gets no answer, and the client says so (no hang).
let other = InstallerCa::mint("ra1-other", Duration::from_secs(3600)).unwrap();
let them = other.issue_installer(Duration::from_secs(3600)).unwrap();
let wrong = tempfile::tempdir().unwrap();
std::fs::write(wrong.path().join(CA_CERT), &ca.cert_pem).unwrap();
std::fs::write(wrong.path().join(INSTALLER_CERT), &them.cert_pem).unwrap();
std::fs::write(wrong.path().join(INSTALLER_KEY), &them.key_pem).unwrap();
let (ok, out, err) = monetize(&["--door", &at, "--material", &wrong.path().display().to_string(), "box", "certs"]);
assert!(!ok && err.contains(&at), "{out}{err}");
}