crates/monetize-site-buy/tests/browser_paid_page.rs
//! ★ **The ONE order page, in a real browser** (TASKS-4-v2 T16, A28 M11): the shared
//! `paid.js` (`monetize_site_buy::assets::paid_js`) that every site serves at
//! `/paid/{reference}` and `/cancel/{reference}`, where the vendor sends a buyer back. It used
//! to be two copies, Gunnar's wasm panes and Holger's hand port, and Holger's had drifted: it
//! ignored `captured` (a paid order said "Waiting for payment") and followed a pay link with
//! credentials in it.
//!
//! The page (`tests/browser/paid.html`, the elements of `assets::PAID_IDS`) runs in headless
//! Chromium over a stub of the shared relay's status door, which answers a canned order by its
//! reference. What the reader sees is read back from the page's `verdict`.
//!
//! Needs `CHROME` (a Chromium binary, as holger-ui's browser image sets it); without it the
//! test says NOT RUN and returns, and with `MONETIZE_NEEDS_BROWSER` set (the CI's `cargo xtask
//! browser`, in the browser image) it FAILS instead.
use monetize_site_buy::assets::{paid_js, Back, Settings, PAID_IDS};
use serde_json::{json, Value};
const PAGE: &str = include_str!("browser/paid.html");
fn chrome() -> Option<String> {
let c = std::env::var("CHROME").ok().filter(|c| !c.is_empty());
if c.is_none() {
// The CI's browser step (`cargo xtask browser`) requires the browser: red, never NOT RUN.
assert!(std::env::var_os("MONETIZE_NEEDS_BROWSER").is_none(), "MONETIZE_NEEDS_BROWSER is set and CHROME names no Chromium: this browser test cannot run (`cargo xtask browser` runs it in the browser image)");
eprintln!("NOT RUN: set CHROME to a Chromium binary to open the order page");
}
c
}
fn back() -> Back {
Back { again: "/account/storage".into(), account: "/account".into(), login: "/login".into() }
}
fn settings() -> Settings {
Settings { product: "Gunnar".into(), ..Settings::default() }
}
/// The canned order behind each reference (the relay's `Attempt` JSON), or `None` for a reader
/// who is not signed in (401).
fn attempt(reference: &str) -> Option<Value> {
let waiting = |url: &str| json!({ "reference": reference, "state": "pending", "phase": "waiting_for_payment", "url": url, "cancellable": true, "after": { "pack_bytes": 2u64 << 30 }, "before": { "pack_bytes": 1u64 << 30 } });
Some(match reference {
// An answer with no phase word (a Start's, or an older monetize): the ledger's word and
// whether it holds a capture. Paid, the room on its way.
"captured" => json!({ "reference": reference, "state": "pending", "captured": true, "url": "https://pay.mock.invalid/c/1" }),
"not-captured" => json!({ "reference": reference, "state": "pending", "captured": false, "url": "https://pay.mock.invalid/c/1" }),
"good-link" => waiting("https://pay.mock.invalid/c/1"),
"credentials-link" => waiting("https://pay.mock.invalid@evil.mock.invalid/c/1"),
"space-link" => waiting("https://pay.mock.invalid/c/a b"),
"plain-http-link" => waiting("http://pay.mock.invalid/c/1"),
"no-host-link" => waiting("https:///c/1"),
"done" => json!({ "reference": reference, "state": "paid", "phase": "done", "after": { "pack_bytes": 2u64 << 30 } }),
_ => return None,
})
}
/// The site on loopback: the order page (the shared script filled in) and the status door.
async fn site() -> String {
let page = PAGE.replacen("/*@paid-script*/", &paid_js(&settings(), &back()), 1);
let page2 = page.clone();
let app = axum::Router::new()
.route("/paid/{*reference}", axum::routing::get(move || async move { axum::response::Html(page) }))
.route("/cancel/{*reference}", axum::routing::get(move || async move { axum::response::Html(page2) }))
.route(
"/api/account/subscription/status",
axum::routing::get(|q: axum::extract::Query<std::collections::BTreeMap<String, String>>| async move {
use axum::response::IntoResponse;
match attempt(q.get("reference").map(String::as_str).unwrap_or("")) {
Some(a) => axum::Json(a).into_response(),
None => (axum::http::StatusCode::UNAUTHORIZED, axum::Json(json!({ "error": "sign in" }))).into_response(),
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
tokio::spawn(async move { axum::serve(listener, app).await });
base
}
/// What a reader sees at `url` once the page has settled.
async fn seen(chrome: &str, url: &str) -> Value {
let (chrome, url) = (chrome.to_owned(), url.to_owned());
tokio::task::spawn_blocking(move || {
let profile = profile_dir(&url);
std::fs::create_dir_all(&profile).unwrap();
let out = std::process::Command::new(&chrome)
.args(["--headless", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--no-first-run"])
.arg(format!("--user-data-dir={}", profile.display()))
.arg("--virtual-time-budget=2500")
.arg("--dump-dom")
.arg(&url)
.output()
.expect("chromium runs");
let _ = std::fs::remove_dir_all(&profile);
let dom = String::from_utf8_lossy(&out.stdout).into_owned();
let verdict = dom.split("<pre id=\"verdict\">").nth(1).and_then(|r| r.split("</pre>").next()).unwrap_or("");
let verdict = verdict.replace(""", "\"").replace("<", "<").replace(">", ">").replace("&", "&");
serde_json::from_str(&verdict).unwrap_or_else(|e| panic!("no verdict at {url} ({e}):\n{dom}\n{}", String::from_utf8_lossy(&out.stderr)))
})
.await
.unwrap()
}
fn buttons(v: &Value) -> Vec<String> {
v["buttons"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect()
}
#[test]
fn the_test_page_carries_every_element_the_script_fills() {
for id in PAID_IDS {
assert!(PAGE.contains(&format!("id=\"{id}\"")), "#{id}");
}
}
/// ★ **A captured order is paid, never "Waiting for payment"**: with no phase word, the
/// ledger's `pending` and a capture is "Paid – adding room", with no way to pay again.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_captured_order_is_paid_and_adding_not_waiting() {
let Some(chrome) = chrome() else { return };
let base = site().await;
let v = seen(&chrome, &format!("{base}/paid/captured")).await;
assert_eq!(v["headline"], "Paid – adding room", "{v}");
assert!(buttons(&v).iter().all(|b| b != "Resume payment"), "a paid order is never sent to pay again: {v}");
let v = seen(&chrome, &format!("{base}/paid/not-captured")).await;
assert_eq!(v["headline"], "Waiting for payment", "nothing captured yet: {v}");
assert!(buttons(&v).iter().any(|b| b == "Resume payment"), "{v}");
}
/// ★ **The page follows only a link a browser may pay at** (Gunnar's `purchase::pay_url`):
/// `https://`, a host, no credentials, printable ASCII. Any other link is never a button, and the
/// page says the link could not be created.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_pay_link_is_followed_only_when_it_is_a_clean_https_link() {
let Some(chrome) = chrome() else { return };
let base = site().await;
let v = seen(&chrome, &format!("{base}/paid/good-link")).await;
assert_eq!(v["headline"], "Waiting for payment", "{v}");
assert!(buttons(&v).iter().any(|b| b == "Resume payment"), "a clean link is the pay button: {v}");
assert!(buttons(&v).iter().any(|b| b == "Cancel order"), "{v}");
for bad in ["credentials-link", "space-link", "plain-http-link", "no-host-link"] {
let v = seen(&chrome, &format!("{base}/paid/{bad}")).await;
assert!(buttons(&v).iter().all(|b| b != "Resume payment"), "{bad}: never a pay button: {v}");
assert!(v["pay"].as_str().unwrap_or("").contains("could not be created"), "{bad}: the page says so: {v}");
}
}
/// The way back from a cancelled checkout shows the cancelled box over the order, a finished
/// order says so, and a signed-out reader is sent to the site's sign-in page and back.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancelled_done_and_signed_out_orders_read_as_the_site_says() {
let Some(chrome) = chrome() else { return };
let base = site().await;
let v = seen(&chrome, &format!("{base}/cancel/good-link")).await;
assert_eq!(v["cancelled"], true, "{v}");
let v = seen(&chrome, &format!("{base}/paid/done")).await;
assert_eq!(v["headline"], "Done – you now have 2.0 GiB storage", "{v}");
assert!(v["links"].as_array().unwrap().iter().any(|l| l[0] == "Back to your account" && l[1] == "/account"), "{v}");
let v = seen(&chrome, &format!("{base}/paid/signed-out")).await;
assert_eq!(v["headline"], "Sign in to follow this order", "{v}");
assert!(v["links"].as_array().unwrap().iter().any(|l| l[1] == "/login?next=%2Fpaid%2Fsigned-out"), "{v}");
}
/// The Chromium profile directory of one page load: this process's, numbered, so no two loads
/// share one (it was named from a sum over the URL, which two loads can share).
fn profile_dir(_url: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
std::env::temp_dir().join(format!("site-buy-paid-{}-{}", std::process::id(), N.fetch_add(1, Ordering::SeqCst)))
}
/// Two page loads never share a Chromium profile: the tests of this file run in parallel, and a
/// second Chromium on a held profile exits at once (GROW-ORDER's flake, RESUME 13).
#[test]
fn two_page_loads_never_share_a_profile() {
assert_ne!(profile_dir("http://127.0.0.1:1/a"), profile_dir("http://127.0.0.1:1/a"), "the same page twice");
assert_ne!(profile_dir("http://127.0.0.1:1/ab"), profile_dir("http://127.0.0.1:1/ba"), "two pages with the same length and byte sum");
}