crates/monetize-site-buy/tests/browser_welcome_settle.rs
//! ★ **PAID-WORDS: right after paying, the welcome page tells the truth while the account
//! settles** (owner's real-card test on test2.gunnar.rs, 2026-10-26 ~17:40Z). The page said "The
//! shop is temporarily down for maintenance; your account will be ready as soon as it is back"
//! although the ONE box was up: the account was only waiting a few seconds for the product's
//! main, and seconds later it said "Payment received. We have sent a sign-in link".
//!
//! The shared `welcome.js` (`monetize_site_buy::assets::welcome_js`), filled as each site fills
//! it, runs in headless Chromium on a welcome page's markup (`tests/browser/welcome.html`),
//! served on loopback with the crate's relay over the fake ONE box and fake Stripe. The buyer
//! pays once at the vendor ([`FakeOneBox::pay`]); the box then settles the account over a few
//! status polls ([`FakeOneBox::settle_after`]), as a pulled product's box does. Every sentence
//! the page showed is read back from its `verdict`.
//!
//! * A normal settle reads the box's "Setting up your account; this takes a few seconds", never
//! maintenance, and a poll the box does not answer in the middle is not an error on the page.
//! * "Down for maintenance" only when the box says the shop is (its explicit words).
//! * A box that stops answering mid-settle: the page keeps the box's last sentence, then shows
//! the sentence the box sent AHEAD for its silence once it is due (RA4-19: the page keeps no
//! settle bound and no settle words of its own).
//! * The page polls with a bounded backoff: quickly at first, never faster than a second and
//! never slower than every few seconds within the first minutes.
//! * PAID-2: the ready page's button is "Register your security key now", with one line under
//! it: "Or open the link we sent to your e-mail."
//!
//! 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 std::net::SocketAddr;
use std::sync::Arc;
use monetize_site_buy::assets::{welcome_js, Settings};
use monetize_site_buy::testkit::{
FakeOneBox, Shape, TestSite, LONGER_WORDS, MAINTENANCE_WORDS, PAID_ROW, SETTLING_WORDS,
};
use monetize_site_buy::Relay;
use serde_json::Value;
const BEARER: &str = "site-bearer-for-browser-tests";
const PAGE: &str = include_str!("browser/welcome.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 welcome page");
}
c
}
fn settings(shape: Shape) -> Settings {
Settings {
product: shape.word().into(),
storage: format!("{}_", shape.product()),
free_account: format!("{}/account", shape.product()),
free_note: "Free: clone only. Pushing needs a paid plan.".into(),
card_check: "A card check at Stripe (0 kr) proves you are a person.".into(),
beta_line: "Beta: we replicate your data, but cannot guarantee it.".into(),
manage: shape.pages().manage,
account_plan: "account".into(),
here: "site".into(),
bearer_key: String::new(),
status_url: "/auth/status".into(),
status_shape: String::new(),
offer_url: "/auth/buy/offer".into(),
contact: "desk@mock.invalid".into(),
}
}
/// The site on loopback: its welcome page (the shared script filled in) and the relay over the
/// fake ONE box. `http://127.0.0.1:<port>`.
async fn site(shape: Shape, fake: &FakeOneBox) -> (String, Arc<Relay<TestSite>>) {
let s = settings(shape);
let relay = Arc::new(Relay::new(TestSite::over(shape, fake), fake.link()));
let page = PAGE.replacen("/*@storage*/", &s.storage, 1).replacen(
"/*@welcome-script*/",
&welcome_js(&s),
1,
);
let app = monetize_site_buy::router(relay.clone()).route(
"/welcome",
axum::routing::get(move || async move { axum::response::Html(page) }),
);
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.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
});
(base, relay)
}
/// A paid sign-up for `user`, started on the site and paid ONCE at the vendor: the welcome
/// page's address on the site.
async fn paid(
shape: Shape,
fake: &FakeOneBox,
relay: &Arc<Relay<TestSite>>,
base: &str,
user: &str,
) -> String {
use tower::ServiceExt;
let body = serde_json::json!({ "username": user, "email": format!("{user}@mock.invalid"), "plan": format!("{}/{PAID_ROW}", shape.product()) });
let req = axum::http::Request::post("/auth/signup/start")
.header("content-type", "application/json")
.header("x-forwarded-for", "203.0.113.9")
.body(axum::body::Body::from(body.to_string()))
.unwrap();
let res = monetize_site_buy::router(relay.clone())
.oneshot(req)
.await
.unwrap();
assert!(
res.status().is_success(),
"{shape:?}: the sign-up starts: {}",
res.status()
);
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
.await
.unwrap();
let url = serde_json::from_slice::<Value>(&bytes).unwrap()["url"]
.as_str()
.expect("the vendor's page")
.to_owned();
assert!(fake.pay(&url), "{shape:?}: the vendor's paid event");
let id = fake.book().checkout(&url).expect("the checkout").id.clone();
format!("{base}/welcome?session={id}")
}
/// What the buyer saw at `url` over `ms` of the page's (virtual) time: the page's `verdict`.
async fn seen_over(chrome: &str, url: &str, ms: u32) -> Value {
let (chrome, url) = (chrome.to_owned(), url.to_owned());
tokio::task::spawn_blocking(move || {
let profile = tempfile_dir();
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(format!("--virtual-time-budget={ms}"))
.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 tempfile_dir() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let d = std::env::temp_dir().join(format!(
"site-buy-welcome-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::SeqCst)
));
std::fs::create_dir_all(&d).unwrap();
d
}
fn said(v: &Value) -> Vec<String> {
v["said"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
}
fn polls(fake: &FakeOneBox) -> usize {
fake.book().count("Signup.Status")
}
/// ★ **A normal settle of a few seconds reads "Setting up your account", never maintenance**,
/// and a poll the box does not answer in the middle of it is not an error on the page. Then
/// the account is ready, as the owner saw it.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_paid_account_that_settles_for_a_few_seconds_reads_setting_up_never_maintenance() {
let Some(chrome) = chrome() else { return };
for shape in [Shape::Gunnar, Shape::Holger] {
let fake = FakeOneBox::spawn(shape.product(), BEARER).await;
let (base, relay) = site(shape, &fake).await;
let welcome = paid(shape, &fake, &relay, &base, "settler").await;
fake.settle_after(3);
fake.status_fails_at(&[2]);
let v = seen_over(&chrome, &welcome, 30_000).await;
let lines = said(&v);
assert!(
polls(&fake) >= 4,
"{shape:?}: the page asked until the account was made: {lines:?}"
);
for l in &lines {
assert!(
!l.to_lowercase().contains("maintenance"),
"{shape:?}: a normal settle said maintenance: {l:?} (all: {lines:?})"
);
assert!(!l.ends_with("| err"), "{shape:?}: a settle (and one unanswered poll in it) showed an error: {l:?} (all: {lines:?})");
}
assert!(
lines.iter().any(|l| l.contains(SETTLING_WORDS)),
"{shape:?}: the settle is said in the buyer's words: {lines:?}"
);
assert_eq!(v["headline"], "Your account is ready", "{shape:?}: {v}");
assert!(
v["detail"]
.as_str()
.unwrap_or("")
.starts_with("Payment received. We’ve sent a sign-in link to b***@mock.invalid"),
"{shape:?}: {v}"
);
}
}
/// ★ **PAID-2: the ready page's button says what it does**: "Register your security key now",
/// with ONE line under it, "Or open the link we sent to your e-mail."
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_ready_page_offers_register_your_security_key_now_and_says_where_the_link_went() {
let Some(chrome) = chrome() else { return };
for shape in [Shape::Gunnar, Shape::Holger] {
let fake = FakeOneBox::spawn(shape.product(), BEARER).await;
let (base, relay) = site(shape, &fake).await;
let welcome = paid(shape, &fake, &relay, &base, "ready").await;
let v = seen_over(&chrome, &welcome, 10_000).await;
assert_eq!(v["headline"], "Your account is ready", "{shape:?}: {v}");
let links = v["links"].as_array().cloned().unwrap_or_default();
assert_eq!(links.len(), 1, "{shape:?}: one way to register here: {v}");
assert_eq!(
links[0][0], "Register your security key now",
"{shape:?}: {v}"
);
assert!(
links[0][1].as_str().unwrap_or("").starts_with('/'),
"{shape:?}: the link stays on the site: {v}"
);
let foot = v["foot"].as_str().unwrap_or("");
assert!(
foot.ends_with("Or open the link we sent to your e-mail."),
"{shape:?}: the one line under the button: {foot:?}"
);
assert!(
!foot.contains("Register it here"),
"{shape:?}: the old words are gone: {foot:?}"
);
assert_eq!(
v["buttons"],
serde_json::json!(["Send again"]),
"{shape:?}: {v}"
);
}
}
/// **"Down for maintenance" only when the shop says so**: the box's explicit maintenance words
/// are shown as the box says them (the control for the test above).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_page_says_maintenance_only_when_the_shop_says_so() {
let Some(chrome) = chrome() else { return };
let shape = Shape::Gunnar;
let fake = FakeOneBox::spawn(shape.product(), BEARER).await;
let (base, relay) = site(shape, &fake).await;
let welcome = paid(shape, &fake, &relay, &base, "waiter").await;
fake.settle_after(1_000);
fake.in_maintenance(true);
let v = seen_over(&chrome, &welcome, 15_000).await;
assert_eq!(
v["detail"], MAINTENANCE_WORDS,
"the box's own maintenance words: {v}"
);
}
/// ★ **A box that stops answering mid-settle** (RA4-19): the page keeps the box's last sentence
/// and asks on a bounded backoff (not every second, not given up), never maintenance; once the
/// box's own bound is due, it shows the sentence the box sent AHEAD for its silence — taking
/// longer, the payment safe. The page has no bound and no settle sentence of its own.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_box_that_stops_answering_keeps_its_last_sentence_then_the_one_it_sent_ahead() {
let Some(chrome) = chrome() else { return };
let shape = Shape::Holger;
let fake = FakeOneBox::spawn(shape.product(), BEARER).await;
let (base, relay) = site(shape, &fake).await;
let welcome = paid(shape, &fake, &relay, &base, "patient").await;
fake.settle_after(100_000);
fake.status_fails_at(&(2..=100_000).collect::<Vec<_>>());
// The first minute: asked quickly at first, then backing off, never hammering; the box's
// one answer is what the page says.
let v = seen_over(&chrome, &welcome, 60_000).await;
let first_minute = polls(&fake);
assert!(
(8..=40).contains(&first_minute),
"a bounded backoff asks 8 to 40 times in a minute, not {first_minute}: {v}"
);
for l in said(&v) {
assert!(
!l.to_lowercase().contains("maintenance"),
"an unanswered box is not maintenance: {l:?}"
);
assert!(
!l.ends_with("| err"),
"an unanswered box is not an error: {l:?}"
);
}
assert_eq!(
v["detail"], SETTLING_WORDS,
"the box's last sentence stays: {v}"
);
// Past the box's bound (a fresh box and page: a reload asks anew): its last sentence, then
// the sentence it sent ahead, as it wrote it.
let fake = FakeOneBox::spawn(shape.product(), BEARER).await;
let (base, relay) = site(shape, &fake).await;
let welcome = paid(shape, &fake, &relay, &base, "patient").await;
fake.settle_after(100_000);
fake.status_fails_at(&(2..=100_000).collect::<Vec<_>>());
let v = seen_over(&chrome, &welcome, 200_000).await;
assert!(
said(&v).iter().any(|l| l.contains(SETTLING_WORDS)),
"the box's answer was said first: {v}"
);
assert_eq!(
v["detail"], LONGER_WORDS,
"the box's sentence for its silence: {v}"
);
}