crates/gunnar-ui-server/tests/open_build_speaks_no_money.rs
//! **The open console speaks no money** — the twin of gunnar's own
//! `gunnar-server/tests/open_build_speaks_no_money.rs`.
//!
//! # The owner's ruling, 2026-09-15
//!
//! > The OPEN-SOURCE gunnar-ui (built WITHOUT `metered`) shall NOT mention
//! > money anywhere a user or operator can see it — UI text, `--help`,
//! > error/refusal messages, served JSON field names and values — stays
//! > UNLIMITED (no quotas, no buy/request-more flow), but SHOULD show how much
//! > storage the box has.
//!
//! Three halves, each saying what it did:
//!
//! * **the ROUTES**, driven — a default build's router is asked for every money
//! path and must answer 404. `cfg(not(feature = "metered"))`.
//!
//! * **the WORDS the console can render**, scanned — every string literal in
//! `gunnar-ui-web/src` (bar the one metered page) must be free of the money
//! vocabulary. **It never skips**, in either build: the wasm bundle is ONE
//! artifact, so a money word typed into a shared panel ships to everybody.
//!
//! * **the WORDS the open BFF can say** — every string literal an open
//! `gunnar-ui-server` compiles: its `--help`, its refusal and error bodies,
//! its JSON values, its served pages. Comments are not scanned, nor is code
//! the open build does not contain (`#[cfg(feature = "metered")]` items and
//! modules, `#[cfg(test)]`/`#[test]` items). `cfg(not(feature = "metered"))`.
//!
//! The served JSON's field NAMES are Rust identifiers, not literals; the one
//! JSON a new panel adds (`/api/dataset`) is asserted word by word in
//! `api::usage`'s own test, and a live guest's JSON is judged by gunnar's
//! capture half (`GUNNAR_OPEN_GUARD_CAPTURE`).
//!
//! # The word list and the judge are gunnar's, verbatim
//!
//! Copied from gunnar's file rather than re-argued — including the contextual
//! rule: `plan`, `checkout`, `renew` and `subscribe` are money only when
//! nothing in the same literal makes them technical ([`TECHNICAL_CONTEXT`]),
//! and `stripe` only when it spells the vendor (`Stripe`, `stripe.com`). Two
//! spellings of one rule across two repositories is how one of them goes
//! stale, so when one changes the other is changed in the same breath.
use std::path::{Path, PathBuf};
const MONEY: &[&str] = &[
"price", "prices", "priced", "pricing",
"pay", "pays", "paying", "paid", "payable", "payment", "payments", "paywall",
"buy", "buys", "buying", "bought", "buyer", "buyers",
"purchase", "purchases", "purchased", "purchasing",
"sell", "sells", "selling", "sold", "seller", "sellers",
"bill", "bills", "billed", "billing", "billable",
"invoice", "invoices", "invoicing",
"checkout", "checkouts",
"money", "currency", "currencies", "sek", "eur", "usd",
"stripe",
"plan", "plans",
"quota", "quotas",
"subscription", "subscriptions", "subscribe",
"renew", "renews", "renewal", "renewals",
"refund", "refunds", "revenue",
];
const CONTEXTUAL: &[&str] = &[
"plan", "plans", "checkout", "checkouts", "renew", "renews", "renewal", "renewals",
"subscription", "subscriptions", "subscribe",
];
const TECHNICAL_CONTEXT: &[&str] = &[
"interface", "interfaces", "lease", "leases", "dhcp", "dhcprequest", "address", "addresses",
"route", "routes", "network", "net",
"event", "events", "bus", "watch", "watchrequest", "replication", "webhook", "runner",
"subscriber", "stream",
"git", "tree", "worktree", "commit", "branch", "clone", "entry", "build",
"grow", "growth", "sectors", "blocks", "resize", "layout", "install", "reset", "passkeys",
];
fn words(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|w| !w.is_empty())
.map(|w| w.to_ascii_lowercase())
.collect()
}
/// **The one judge**, gunnar's.
fn leaks_in(text: &str) -> Vec<String> {
let ws = words(text);
let technical = ws.iter().any(|w| TECHNICAL_CONTEXT.contains(&w.as_str()));
let mut out = Vec::new();
for w in &ws {
if !MONEY.contains(&w.as_str()) {
continue;
}
if CONTEXTUAL.contains(&w.as_str()) && technical {
continue;
}
if w == "stripe" && !(text.contains("Stripe") || text.contains("STRIPE") || text.contains("stripe.com")) {
continue;
}
out.push(w.clone());
}
out.sort();
out.dedup();
out
}
#[test]
fn the_judge_reads_the_sentence() {
assert_eq!(leaks_in("Buy 100:- 10 GB one month"), vec!["buy"]);
assert!(leaks_in("payload").is_empty());
assert_eq!(leaks_in("request a bigger quota"), vec!["quota"]);
assert_eq!(leaks_in("your plan"), vec!["plan"]);
assert!(leaks_in("off-stripe").is_empty());
assert!(leaks_in("a reset plan for the passkeys").is_empty());
}
/// **The one file the web scan steps over**, and the step-over is asserted.
/// **The files a community build does not contain**, and therefore the only
/// ones allowed to say what it must never say.
///
/// A list rather than a single name since 2026-09-17, when the customer's own
/// account pane joined the "more room" panel behind `metered`. Every entry
/// widens the area this scanner is NOT watching, so each one is a decision:
/// the file must be genuinely absent from an open build, and
/// `only_one_module_is_behind_the_feature` in the same suite holds
/// `pages/mod.rs` to exactly this set so the two cannot drift apart.
// `purchase.rs` joined 2026-09-28: the customer's order words (price with
// VAT, "Pay with Stripe", the steps), host-side so they are tested, and gated
// `#[cfg(feature = "metered")]` in lib.rs exactly as the two pages are in
// pages/mod.rs.
const METERED_ONLY: &[&str] = &["pages/increase.rs", "pages/subscription.rs", "purchase.rs"];
fn web_src() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../gunnar-ui-web/src")
}
fn sources(dir: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(here) = stack.pop() {
let entries = std::fs::read_dir(&here)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", here.display()));
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
found.push(path);
}
}
}
found.sort();
found
}
// ── the lexer, gunnar's ─────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq)]
enum Tok {
Str(String),
Ident(String),
Punct(char),
}
#[derive(Default)]
struct Scanned {
literals: Vec<(usize, String)>,
skipped_mods: Vec<String>,
}
fn lex(src: &str) -> Vec<(usize, Tok)> {
let b: Vec<char> = src.chars().collect();
let mut out = Vec::new();
let (mut i, mut line) = (0usize, 1usize);
while i < b.len() {
let c = b[i];
if c == '\n' {
line += 1;
i += 1;
continue;
}
if c.is_whitespace() {
i += 1;
continue;
}
if c == '/' && b.get(i + 1) == Some(&'/') {
while i < b.len() && b[i] != '\n' {
i += 1;
}
continue;
}
if c == '/' && b.get(i + 1) == Some(&'*') {
let mut depth = 0;
while i < b.len() {
if b[i] == '/' && b.get(i + 1) == Some(&'*') {
depth += 1;
i += 2;
} else if b[i] == '*' && b.get(i + 1) == Some(&'/') {
depth -= 1;
i += 2;
if depth == 0 {
break;
}
} else {
if b[i] == '\n' {
line += 1;
}
i += 1;
}
}
continue;
}
let raw_start = if c == 'r' && matches!(b.get(i + 1), Some('"') | Some('#')) {
Some(i + 1)
} else if c == 'b' && b.get(i + 1) == Some(&'r') && matches!(b.get(i + 2), Some('"') | Some('#')) {
Some(i + 2)
} else {
None
};
if let Some(mut j) = raw_start {
let mut hashes = 0;
while b.get(j) == Some(&'#') {
hashes += 1;
j += 1;
}
if b.get(j) == Some(&'"') {
let start_line = line;
j += 1;
let mut text = String::new();
while j < b.len() {
if b[j] == '"' && (0..hashes).all(|k| b.get(j + 1 + k) == Some(&'#')) {
j += 1 + hashes;
break;
}
if b[j] == '\n' {
line += 1;
}
text.push(b[j]);
j += 1;
}
out.push((start_line, Tok::Str(text)));
i = j;
continue;
}
}
if c == '"' || (c == 'b' && b.get(i + 1) == Some(&'"')) {
let start_line = line;
let mut j = if c == 'b' { i + 2 } else { i + 1 };
let mut text = String::new();
while j < b.len() {
match b[j] {
'\\' => {
if b.get(j + 1) == Some(&'\n') {
line += 1;
j += 2;
while j < b.len() && b[j].is_whitespace() {
if b[j] == '\n' {
line += 1;
}
j += 1;
}
text.push(' ');
continue;
}
if let Some(n) = b.get(j + 1) {
text.push(match n {
'n' => '\n',
't' => ' ',
other => *other,
});
}
j += 2;
}
'"' => {
j += 1;
break;
}
ch => {
if ch == '\n' {
line += 1;
}
text.push(ch);
j += 1;
}
}
}
out.push((start_line, Tok::Str(text)));
i = j;
continue;
}
if c == '\'' {
if b.get(i + 1) == Some(&'\\') {
let mut j = i + 2;
while j < b.len() && b[j] != '\'' {
j += 1;
}
i = j + 1;
continue;
}
if b.get(i + 2) == Some(&'\'') {
i += 3;
continue;
}
i += 1;
continue;
}
if c.is_alphanumeric() || c == '_' {
let mut j = i;
let mut ident = String::new();
while j < b.len() && (b[j].is_alphanumeric() || b[j] == '_') {
ident.push(b[j]);
j += 1;
}
out.push((line, Tok::Ident(ident)));
i = j;
continue;
}
out.push((line, Tok::Punct(c)));
i += 1;
}
out
}
fn removes_from_open_build(attr: &[Tok]) -> bool {
let t: String = attr
.iter()
.map(|t| match t {
Tok::Str(s) => format!("\"{s}\""),
Tok::Ident(s) => s.clone(),
Tok::Punct(c) => c.to_string(),
})
.collect();
if t == "test" || t == "tokio::test" || t.starts_with("tokio::test(") {
return true;
}
if !t.starts_with("cfg(") || t.contains("not(") {
return false;
}
t == "cfg(test)" || t.starts_with("cfg(all(test") || t.contains("feature=\"metered\"") || (t.starts_with("cfg(all(") && t.contains("test,"))
}
fn skip_item(toks: &[(usize, Tok)], mut i: usize, mods: &mut Vec<String>) -> usize {
while matches!(toks.get(i), Some((_, Tok::Punct('#')))) && matches!(toks.get(i + 1), Some((_, Tok::Punct('[')))) {
let mut depth = 0;
let mut j = i + 1;
while j < toks.len() {
match toks[j].1 {
Tok::Punct('[') => depth += 1,
Tok::Punct(']') => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
i = j + 1;
}
let mut depth = 0i32;
// `<…>` in the item's own header (`-> Result<A, B>`, `HashMap<K, V>`):
// its comma does not end the item. Counted only outside brackets, where
// a `<` cannot be a comparison.
let mut angle = 0i32;
let mut prev_mod = false;
while i < toks.len() {
match &toks[i].1 {
Tok::Punct('<') if depth == 0 => angle += 1,
Tok::Punct('>') if depth == 0 && angle > 0 && !matches!(toks.get(i.wrapping_sub(1)), Some((_, Tok::Punct('-' | '=')))) => angle -= 1,
Tok::Ident(s) if s == "mod" => prev_mod = true,
Tok::Ident(s) if prev_mod => {
mods.push(s.clone());
prev_mod = false;
}
Tok::Punct('(') | Tok::Punct('[') | Tok::Punct('{') => depth += 1,
Tok::Punct(')') | Tok::Punct(']') => depth -= 1,
Tok::Punct('}') => {
depth -= 1;
if depth == 0 {
return i + 1;
}
if depth < 0 {
return i;
}
}
Tok::Punct(';') if depth == 0 => return i + 1,
Tok::Punct(',') if depth == 0 && angle == 0 => return i + 1,
_ => {}
}
i += 1;
}
i
}
fn scan_source(src: &str) -> Scanned {
let toks = lex(src);
let mut out = Scanned::default();
let mut i = 0usize;
while i < toks.len() {
match &toks[i].1 {
Tok::Punct('#') => {
let inner = matches!(toks.get(i + 1), Some((_, Tok::Punct('!'))));
let open = if inner { i + 2 } else { i + 1 };
if !matches!(toks.get(open), Some((_, Tok::Punct('[')))) {
i += 1;
continue;
}
let mut depth = 0i32;
let mut j = open;
loop {
match toks.get(j).map(|t| &t.1) {
Some(Tok::Punct('[')) => depth += 1,
Some(Tok::Punct(']')) => {
depth -= 1;
if depth == 0 {
break;
}
}
None => break,
_ => {}
}
j += 1;
}
let end = j.min(toks.len());
let attr: Vec<Tok> = toks[open + 1..end].iter().map(|t| t.1.clone()).collect();
if removes_from_open_build(&attr) {
if inner {
return Scanned::default();
}
i = skip_item(&toks, j + 1, &mut out.skipped_mods);
continue;
}
let quiet = matches!(attr.first(), Some(Tok::Ident(s)) if s == "doc" || s == "cfg" || s == "cfg_attr");
if !quiet {
for (line, t) in &toks[open + 1..end] {
if let Tok::Str(s) = t {
out.literals.push((*line, s.clone()));
}
}
}
i = j + 1;
}
Tok::Str(s) => {
out.literals.push((toks[i].0, s.clone()));
i += 1;
}
_ => i += 1,
}
}
out
}
#[test]
fn the_scanner_skips_what_the_open_build_does_not_contain() {
let s = scan_source(
r####"
/// a doc about billing
fn open() { let _ = "free space"; }
#[cfg(feature = "metered")]
fn metered() { let _ = "paid"; }
#[cfg(feature = "metered")]
fn generic(raw: String) -> Result<String, Vec<u8>> { let _ = "checkout"; Ok(raw) }
#[cfg(test)]
mod tests { fn t() { let _ = "invoice"; } }
#[cfg(feature = "metered")]
pub mod increase;
const C: char = '"';
fn tail() { let _ = "after"; }
"####,
);
let texts: Vec<&str> = s.literals.iter().map(|l| l.1.as_str()).collect();
assert_eq!(texts, vec!["free space", "after"]);
assert_eq!(s.skipped_mods, vec!["tests", "increase"]);
}
// ── the web half: never skips ───────────────────────────────────────────────
/// **The console's rendered words, in every build.**
#[test]
fn the_consoles_own_words_are_free_of_money() {
let root = web_src();
let mut leaks = Vec::new();
let mut skipped = Vec::new();
for path in sources(&root) {
let relative = path
.strip_prefix(&root)
.expect("under the root")
.to_string_lossy()
.replace('\\', "/");
if METERED_ONLY.contains(&relative.as_str()) {
skipped.push(relative);
continue;
}
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
for (line, literal) in scan_source(&text).literals {
for word in leaks_in(&literal) {
leaks.push(format!("{relative}:{line}: `{word}` in {literal:?}"));
}
}
}
// The bundle's non-Rust half: the page shell, the styles, the vector art.
let crate_root = root.parent().expect("the web crate").to_path_buf();
let mut shells = vec![crate_root.join("index.html")];
for dir in ["styles", "assets"] {
if let Ok(entries) = std::fs::read_dir(crate_root.join(dir)) {
for e in entries.filter_map(Result::ok) {
let p = e.path();
if p.extension().is_some_and(|x| x == "css" || x == "svg" || x == "html" || x == "js") {
shells.push(p);
}
}
}
}
for p in shells {
let text = std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display()));
for (n, para) in text.split("\n\n").enumerate() {
for word in leaks_in(para) {
leaks.push(format!("{} ¶{}: `{word}` in {:?}", p.display(), n + 1, para.trim()));
}
}
}
skipped.sort();
let mut expected: Vec<String> = METERED_ONLY.iter().map(|s| (*s).to_owned()).collect();
expected.sort();
assert_eq!(
skipped,
expected,
"the skip list must match what is on disk exactly; a file that vanished, or a second \
one that was quietly added, both land here"
);
assert!(
leaks.is_empty(),
"the gunnar console can render money vocabulary. gunnar takes no money and this console \
is the same code for a self-hoster as for a paying tenant; every string below belongs \
to monetize, which renders its own words through the seam.\n {}",
leaks.join("\n ")
);
}
/// **`pages/increase.rs` is the ONLY module behind the feature**, so the skip
/// above cannot silently grow into a hole.
#[test]
fn only_one_module_is_behind_the_feature() {
let mod_rs = web_src().join("pages/mod.rs");
let text = std::fs::read_to_string(&mod_rs).expect("the pages module");
let gated: Vec<String> = text
.lines()
.collect::<Vec<_>>()
.windows(2)
.filter(|pair| pair[0].trim() == "#[cfg(feature = \"metered\")]")
.map(|pair| pair[1].trim().to_owned())
.collect();
assert_eq!(
gated,
vec!["pub mod increase;".to_owned(), "pub mod subscription;".to_owned()],
"the modules hidden behind `metered` must be exactly the ones \
`the_consoles_own_words_are_free_of_money` skips (`METERED_ONLY`). Anything gated here \
and not on that list is a file the money scanner silently stops watching"
);
}
// ── the BFF half: what an OPEN gunnar-ui-server compiles ────────────────────
/// **Deliberate exceptions**, `(file suffix, fragment, reason)`. Each must
/// still match something.
#[cfg(not(feature = "metered"))]
const SERVER_EXCEPTIONS: &[(&str, &str, &str)] = &[
(
"src/security.rs",
"payment=()",
"the W3C Permissions-Policy feature token that DENIES the browser's Payment Request API \
to every frame; a response header no person reads, and removing it would widen the policy",
),
(
"src/envfile.rs",
"GUNNAR_UI_BUY_URL",
"an env-file KEY the open build must still RECOGNISE in order to refuse an install that \
sets it (`lifecycle::Refusal::ConfiguredToSellButCannot`) — the twin of gunnar's \
`--monetize-pubkey`, kept so a community binary is never deployed behind a signup page \
it cannot answer",
),
];
#[cfg(not(feature = "metered"))]
#[test]
fn the_open_bff_says_no_money() {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let files: Vec<(PathBuf, Scanned)> = sources(&src)
.into_iter()
.filter(|p| {
let n = p.file_name().unwrap().to_string_lossy();
!(n == "tests.rs" || n.ends_with("_tests.rs"))
})
.map(|p| {
let text = std::fs::read_to_string(&p).unwrap_or_default();
let s = scan_source(&text);
(p, s)
})
.collect();
let mut dead: Vec<PathBuf> = Vec::new();
for (p, s) in &files {
let stem = p.file_stem().unwrap().to_string_lossy();
let base = if matches!(stem.as_ref(), "lib" | "main" | "mod") {
p.parent().unwrap().to_path_buf()
} else {
p.parent().unwrap().join(stem.as_ref())
};
for m in &s.skipped_mods {
dead.push(base.join(format!("{m}.rs")));
dead.push(base.join(m));
}
}
let mut leaks = Vec::new();
let mut used = vec![false; SERVER_EXCEPTIONS.len()];
let mut scanned = 0usize;
for (p, s) in &files {
if dead.iter().any(|d| p == d || p.starts_with(d)) {
continue;
}
scanned += 1;
let rel = p.strip_prefix(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap_or(p).to_string_lossy().replace('\\', "/");
for (line, lit) in &s.literals {
let hits = leaks_in(lit);
if hits.is_empty() {
continue;
}
if let Some(k) = SERVER_EXCEPTIONS.iter().position(|(f, frag, _)| rel.ends_with(f) && lit.contains(frag)) {
used[k] = true;
continue;
}
leaks.push(format!("{rel}:{line}: {hits:?} in {lit:?}"));
}
}
// ── the served ASSETS a live source `include_str!`s ────────────────────
//
// A page the BFF serves is words to a visitor, comments and markup
// included (view-source is one keystroke). Any literal naming an
// `.html`/`.css`/`.js`/`.svg` beside a LIVE source is that file; an asset
// only a `metered` item includes is not in this list, by the same cfg
// walk that stepped over the item.
let mut assets = 0usize;
for (p, s) in &files {
if dead.iter().any(|d| p == d || p.starts_with(d)) {
continue;
}
for (_, lit) in &s.literals {
if !["html", "css", "js", "svg"].iter().any(|x| lit.ends_with(&format!(".{x}"))) || lit.contains('/') {
continue;
}
let asset = p.parent().unwrap().join(lit);
let Ok(text) = std::fs::read_to_string(&asset) else { continue };
assets += 1;
for (n, para) in text.split("\n\n").enumerate() {
let hits = leaks_in(para);
if !hits.is_empty() {
leaks.push(format!("{} ¶{}: {hits:?} in {:?}", asset.display(), n + 1, para.trim()));
}
}
}
}
assert!(assets >= 1, "no served asset found; login.html is expected");
assert!(scanned > 30, "only {scanned} files scanned; the layout moved");
// The metered modules really were stepped over, by the attribute and not by luck.
for metered in ["src/api/increase.rs", "src/monetize.rs"] {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(metered);
assert!(
dead.iter().any(|d| &path == d),
"{metered} is expected to be a `#[cfg(feature = \"metered\")]` module"
);
}
let stale: Vec<_> = SERVER_EXCEPTIONS.iter().zip(&used).filter(|(_, u)| !**u).map(|(e, _)| e.0).collect();
assert!(stale.is_empty(), "exceptions that no longer match anything: {stale:?}");
assert!(
leaks.is_empty(),
"an OPEN gunnar-ui-server compiles money vocabulary into what it says ({} literals). \
Reword neutrally, or gate the code under `metered`.\n {}",
leaks.len(),
leaks.join("\n ")
);
}
/// **A default build's router has no money route at all.**
#[cfg(not(feature = "metered"))]
#[tokio::test]
async fn the_open_console_serves_no_money_route() {
use std::sync::Arc;
use tower::ServiceExt as _;
let signer = Arc::new(
gunnar_ui_server::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&[3u8; 32]),
))
.expect("a fixed seed makes a signer"),
);
let store = gunnar_ui_server::state::StateStore::open(
tempfile::Builder::new()
.suffix(".redb")
.tempfile()
.expect("a temp file")
.into_temp_path()
.keep()
.expect("keep the path"),
)
.expect("a state store");
let gunnar =
gunnar_ui_server::grpc::Gunnar::connect_lazy("127.0.0.1:1", signer).expect("a lazy handle");
let router = gunnar_ui_server::serve::router(Arc::new(gunnar_ui_server::app::App {
webauthn: None,
admin_token: None,
events: gunnar_ui_server::sse::EventHub::detached(),
gunnar,
twin: None,
guest: None,
reviewers: Arc::new(gunnar_ui_server::reviewer::ReviewerKeys::new(
"127.0.0.1:1".to_owned(),
store.clone(),
)),
tenants: Arc::new(gunnar_ui_server::tenant::TenantKeys::new(
"127.0.0.1:1".to_owned(),
store,
)),
forges: Default::default(),
imports: Default::default(),
forge_apps: Default::default(),
ui_dir: None,
caches: gunnar_ui_server::api::Caches::default(),
objects: Default::default(),
readmes: Default::default(),
}));
for (method, path) in [
// `/welcome` is where a payment vendor returns somebody who has just
// bought an account, and `/auth/signup/status` is what that page polls.
// Neither is in this binary.
(http::Method::GET, "/welcome?session=cs_test_a1b2c3d4e5"),
(
http::Method::GET,
"/auth/signup/status?session=cs_test_a1b2c3d4e5",
),
(http::Method::GET, "/api/namespaces/alpha/increase"),
(http::Method::POST, "/api/namespaces/alpha/increase"),
(
http::Method::GET,
"/api/namespaces/alpha/increase/offer?meter=pack_bytes&cap=1",
),
(
http::Method::GET,
"/api/namespaces/alpha/increase/status?reference=gunnar/alpha/2026-09-07",
),
// The shop window and the button under it. Added 2026-09-19 with the
// routes themselves: a money route that was not in this list would be
// absent from an open build by accident rather than by test.
(http::Method::GET, "/api/namespaces/alpha/increase/bundles"),
(http::Method::POST, "/api/namespaces/alpha/increase/bundle"),
(http::Method::GET, "/api/account/subscription/bundles"),
(http::Method::POST, "/api/account/subscription/buy-bundle"),
] {
let response = router
.clone()
.oneshot(
http::Request::builder()
.method(method.clone())
.uri(path)
.body(axum::body::Body::empty())
.expect("a request"),
)
.await
.expect("the router answers");
assert_eq!(
response.status(),
http::StatusCode::NOT_FOUND,
"the OPEN console answered {method} {path}; a build without `metered` must not have \
the route at all"
);
let body = axum::body::to_bytes(response.into_body(), 1 << 20)
.await
.expect("a body");
assert!(
!String::from_utf8_lossy(&body).contains("<html"),
"{method} {path} was answered with a document; the open console has no page here"
);
}
}