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_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("&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 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"
    );
}