Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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("&quot;", "\"")
            .replace("&lt;", "<")
            .replace("&gt;", ">")
            .replace("&amp;", "&");
        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}"
    );
}